openapi: 3.0.3
info:
  title: UpHex API
  version: "0.1.0"
  description: |
    Developer-owned API edge for UpHex integrations. This service controls
    developer apps, credentials, request logs, webhooks, MCP access, and public
    API contracts while delegating domain behavior to the existing UpHex services.

    Edge contract: authentication headers must be sent at most once. Policy
    headers such as `X-Agency-Id`, `Idempotency-Key`, `Origin`, and
    `Mcp-Protocol-Version` must also be sent at most once when present. CORS
    origins and requested preflight headers are bounded before browser access
    headers are returned. `Idempotency-Key` must be sent at most once.
    Malformed or duplicated `Content-Length` headers are rejected before route
    handling. External request bodies are limited to 1 MB before parsing and
    during parser reads, including clients that omit `Content-Length`, except
    `POST /v1/templates/{id}/images`, which uses a 15 MB route-specific body
    allowance for encoded images. Decoded uploaded images remain limited to 10 MB.

    Creative write beta contract: REST `templates:write` and MCP `mcp:write`
    create, update, upload images to, and soft-delete only CMS-owned API draft
    templates for the resolved agency. No public creative write route launches,
    publishes, activates, or reaches CampaignBuilders/backend campaign launch
    endpoints. Delete means soft-delete of an API-created draft and is the
    rollback path for beta-created draft assets. Stable request-log error codes
    include `invalid_request`, `missing_scope`, `rate_limited`,
    `invalid_idempotency_key`, `idempotency_key_processing`,
    `idempotency_key_conflict`, `downstream_rejected`, `backend_unavailable`, `cms_unavailable`,
    `crm_unavailable`, `client_not_found`, `downstream_timeout`, and `unknown_resource`.
    `POST /v1/leads`, `POST /v1/leads/{id}/notes`,
    `POST /v1/leads/{id}/activities`, `POST /v1/templates`, and
    `POST /v1/templates/{id}/images` support `Idempotency-Key` for API key,
    legacy bearer-token, and OAuth access token requests. Keys are kept for 24
    hours: API-key keys are scoped to the API key, OAuth keys to the OAuth
    client and the token's agency (refreshed or rotated tokens of the same
    client share them; other apps, clients, and agencies never do).
    `POST /v1/leads/{id}/messages` instead requires `Idempotency-Key` for every
    credential type; crm-service holds that key (see the route).

    ## Authentication

    Protected `/v1` routes and `/mcp` accept exactly one of these methods per
    request. Mixing bearer authentication with `X-API-Key`/`X-API-Secret`
    returns `401`.

    1. API key headers: `X-API-Key: uphex_key_...` plus
       `X-API-Secret: uphex_secret_...`. Issue keys per developer app in the
       Developer Portal; the secret is shown once.
    2. API key over HTTP Basic: `Authorization: Basic base64(<api_key>:<api_secret>)`.
    3. Bearer token: `Authorization: Bearer <token>`, where the token is either
       an OAuth access token (`uphex_access_...`, one hour) from `/oauth/token`
       or a one-hour legacy token from `POST /v1/tokens`.

    Every authentication failure returns `401` with
    `{"errors":{"base":["Unauthorized"]}}` and a `WWW-Authenticate` challenge.
    Revoked or expired credentials, and credentials whose developer app or
    account is paused, suspended, or archived, are rejected the same way.

    ## Agency resolution

    Every proxied `/v1` route and `/mcp` runs against one UpHex agency. The
    edge resolves it from `X-Agency-Id` when sent; otherwise from the developer
    account agency, or the app's single installed agency. OAuth access tokens are
    bound to the agency chosen at authorization time; `X-Agency-Id` is optional
    for them but must match when sent. Errors: `400` `missing_agency_id`,
    `400` `invalid_agency_id`, `400` `ambiguous_agency_id` (header sent twice),
    `403` `agency_not_allowed`.

    ## Rate limits

    Authenticated `/v1` and `/mcp` traffic is counted per developer app (all of
    the app's API keys and OAuth tokens share one counter) in fixed one-minute
    windows. The default is 120 requests per minute per app and the
    configurable per-app maximum is 1,000. Responses carry
    `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset`
    (Unix seconds when the window resets). Over the limit the edge returns
    `429` with `Retry-After` (seconds) and `{"errors":{"base":["Rate limit exceeded"]}}`.
    `POST /v1/tokens` is separately limited per source IP (120/minute) and per
    presented API key (30/minute); `/oauth/token` and `/oauth/revoke` per
    source IP (120/minute) and per presented client id (60/minute).

    ## Pagination

    List routes accept `page` (1-based). Page size is fixed by the owning
    service except `GET /v1/templates` and `GET /v1/leads`, which accept
    `per_page` (1-100; leads default to 25).
    Read `meta.page`, `meta.per_page`, `meta.total_entries`, and
    `meta.total_pages` from the response; keep requesting `page + 1` until
    `page >= total_pages`.

    ## Errors

    On protected `/v1` routes, edge-generated errors use
    `{"errors":{"base":[...messages]}}` (`POST /v1/oauth/clients` validation
    errors are keyed by field). Errors returned by backend or CMS on `/v1`
    routes use
    `{"errors":[{"code","title","detail","source"}],"request_id":"..."}`, with
    the downstream 4xx status preserved and downstream 5xx mapped to `502`.
    `/mcp` passes CMS 4xx JSON-RPC bodies through (redacted) and normalizes
    only 5xx. `/oauth/token` and `/oauth/revoke` use OAuth-style
    `{"error":"..."}` bodies.
    Status codes: `400` invalid request, agency header, or idempotency key;
    `401` authentication failed; `403` missing scope, agency not allowed, or a
    downstream refusal; `404` unknown `/v1/{resource}` (`unknown_resource`) or
    downstream not found; `405` `GET`/`DELETE` on `/mcp`; `409` idempotency
    conflict or non-draft template; `413` request body too large; `429` rate
    limited; `502` owning service unavailable; `504` owning service timed out.

    ## Leads

    `/v1/leads` is UpHex's single public lead resource, backed by UpHex's
    CRM (the leads shown in the CRM inbox, pipelines, and workflows). Lead
    ids are CRM lead ids; ids returned before the October 2026 move to the
    CRM are no longer valid, so re-find leads with `GET /v1/leads/lookup`.
    Every lead route requires `client_id`; a client outside the authorized
    agency returns `404` `client_not_found`. Tags, notes, activities,
    pipeline stage, and channel do-not-disturb nest under `/v1/leads/{id}`;
    `/v1/tags` and `/v1/pipelines` list what a client can use. `ghl_tags` is
    a deprecated alias of `tags`. Bulk lead creation was removed.

    ## Calendars and appointments

    `/v1/calendars` and `/v1/appointments` read and book CRM calendars owned
    by crm-service. Every request is scoped to the credential's agency, and
    every `client_id` must belong to that agency (`404` `client_not_found`
    otherwise). Reads need `calendars:read`; booking, rescheduling, and
    canceling need `appointments:write`. Appointment ids are strings:
    `bk_<id>` is a native UpHex booking and `la_<id>` is an appointment synced
    from GHL, Calendly, or Google. Only native, active, free event types are
    bookable through the API (v1 limit); synced appointments are read-only.
    Errors use the shared envelopes: edge validation, scope, agency, and
    `Idempotency-Key` failures return `{"errors":{"base":[...]}}` (`400`/`403`),
    and booking-domain errors return
    `{"errors":[{"code","title","detail","source"}],"request_id":"..."}` with a
    stable `code`: `slot_unavailable` and `idempotency_key_conflict` (`409`);
    `calendar_not_bookable`, `outside_availability`, `lead_missing_contact`,
    `range_too_large`, `appointment_read_only`, `appointment_not_reschedulable`,
    and `appointment_not_cancelable` (`422`); `event_type_not_found`,
    `lead_not_found`, and `appointment_not_found` (`404`); `invalid_request`
    (`400`, a parameter crm-service rejected). Other crm-service rejections use
    `downstream_rejected`, and outages `downstream_unavailable` (`502`) or
    `downstream_timeout` (`504`).

    ## Webhooks

    Webhook endpoints are configured per developer app in the Developer
    Portal. Events: `lead.created`, `lead.updated`, `lead.tag_added`,
    `lead.tag_removed`, `message.inbound`, `message.outbound.status`,
    `appointment.created`, `appointment.rescheduled`, `appointment.canceled`,
    and `app.uninstalled`. Lead events are sent by UpHex's CRM, once per
    change, whatever made it (including `POST /v1/leads`). See `x-webhooks`
    for each delivery payload and, under `lead.created`, the shared headers,
    signature algorithm, and retry policy.
servers:
  - url: /
    description: Current server
security:
  - ApiKey: []
    ApiSecret: []
  - ApiKeyBasic: []
  - BearerAuth: []
paths:
  /.well-known/oauth-authorization-server:
    get:
      summary: OAuth authorization server metadata
      tags: [Discovery]
      security: []
      description: Public metadata that lets OAuth/MCP clients discover the API edge authorization, token, revocation, registration, PKCE, scope contract, and developer documentation URL.
      responses:
        "200":
          description: Authorization server metadata.
  /.well-known/oauth-protected-resource:
    get:
      summary: OAuth protected resource metadata
      tags: [Discovery]
      security: []
      description: Public metadata that identifies `/mcp` as the protected MCP resource and points clients at this authorization server.
      responses:
        "200":
          description: Protected resource metadata.
  /v1/tokens:
    post:
      summary: Exchange API key credentials for a bearer token (Deprecated)
      deprecated: true
      tags: [Authentication]
      security: []
      description: |
        DEPRECATED (90-day dual-run window active: July 28, 2026 – October 26, 2026).
        Legacy-compatible token exchange. Accepts a Developer Portal API key
        (`uphex_key_...`) and secret and returns a one-hour bearer token that
        carries the same scopes and app as that key; it is not a separate
        credential type. Backend `api_clients` keys are not accepted here.
        Primary authentication for all UpHex Public API endpoints
        uses Developer Portal API key headers (X-API-Key, X-API-Secret, X-Agency-Id) sent directly
        to api.uphex.com or OAuth access tokens. Attempts are rate limited by source IP and presented API key
        before credential validation.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [api_key, api_secret]
              properties:
                api_key:
                  type: string
                  maxLength: 128
                  pattern: "^uphex_key_[A-Za-z0-9_-]+$"
                api_secret:
                  type: string
                  maxLength: 160
                  pattern: "^uphex_secret_[A-Za-z0-9_-]+$"
      responses:
        "201":
          description: Bearer token issued.
          headers:
            Cache-Control:
              $ref: "#/components/headers/NoStoreCacheControl"
            Pragma:
              $ref: "#/components/headers/NoCachePragma"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LegacyTokenResponse"
        "400":
          description: Missing API key or secret.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          description: Invalid API key or secret.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "413":
          description: Request body exceeds the API edge 1 MB limit.
        "429":
          description: Legacy token exchange rate limit exceeded.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
  /v1/health:
    get:
      summary: Authenticated API health
      tags: [Health]
      responses:
        "200":
          description: API is healthy and credentials are valid.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthenticatedHealthResponse"
        "401":
          description: Missing or invalid API credentials.
          headers:
            WWW-Authenticate:
              $ref: "#/components/headers/PublicApiAuthChallenge"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
  /v1/clients:
    get:
      summary: List clients
      tags: [Clients]
      description: |
        Requires `clients:read`. Client records are CMS-owned. For
        single-agency apps the API edge resolves agency from the developer
        account. Multi-agency apps may send `X-Agency-Id`; the app policy must
        allow the requested agency before the request is proxied to CMS for
        that agency. Clients are ordered by name. Page size is fixed by CMS
        (read `meta.per_page`); `per_page` is not accepted by the API edge.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - in: query
          name: page
          schema:
            type: integer
            minimum: 1
            default: 1
          example: 1
          description: Page number for CMS-owned pagination.
        - in: query
          name: include_deleted
          schema:
            type: boolean
          description: Include soft-deleted clients when true.
      responses:
        "200":
          description: Normalized data envelope delegated from the CMS service.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicEnvelope"
              example:
                data:
                  - id: 123
                    name: Acme Dental
                    external_id: null
                    website: https://acmedental.example
                    budget: "1500.0"
                    mtd_spend: "420.5"
                    mtd_results: 12
                    mtd_cpa: "35.04"
                    metrics_updated_at: "2026-10-21T06:00:00.000Z"
                    created_at: "2026-01-04T17:22:10.000Z"
                meta:
                  resource: clients
                  page: 1
                  per_page: 50
                  total_entries: 1
                  total_pages: 1
        "400":
          description: Query parameters failed API-edge validation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: The API credential is missing `clients:read`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: CMS service unavailable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "504":
          description: CMS service timed out.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
  /v1/templates:
    get:
      summary: List template creative previews
      tags: [Templates]
      description: |
        Requires `templates:read`. Template data remains CMS-owned; this
        read-only endpoint validates filters at the API edge, resolves agency
        policy, proxies to CMS, and returns a normalized public envelope.
        Provider tokens, raw metadata, prompt data, audit internals, and
        CMS-only support fields are not part of the public contract.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - in: query
          name: platform
          schema:
            type: string
            enum: [facebook, google]
          description: Filter templates by ad platform.
        - in: query
          name: category_id
          schema:
            type: integer
            minimum: 1
            format: int64
          description: Filter by category or child category.
        - in: query
          name: enabled
          schema:
            type: boolean
          description: Filter by template availability.
        - in: query
          name: q
          schema:
            type: string
            maxLength: 255
          description: Optional safe text query. `query` is also accepted.
        - in: query
          name: query
          schema:
            type: string
            maxLength: 255
          description: Alias for `q`; use either text search parameter.
        - in: query
          name: page
          schema:
            type: integer
            minimum: 1
            default: 1
          description: Page number for CMS-owned pagination.
        - in: query
          name: per_page
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
          description: Page size, bounded by the API edge.
      responses:
        "200":
          description: Normalized read-only template preview list.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicEnvelope"
              example:
                data:
                  - id: 12345
                    guid: template-guid
                    name: Disc Rehab Preview
                    platform: facebook
                    enabled: true
                    availability: available
                    category:
                      id: 42
                      name: Chiropractic
                    creative:
                      headline: Move without pain
                      primary_text: Book a simple consult for disc pain.
                      link_description: See available times
                      call_to_action: LEARN_MORE
                    media:
                      - id: 999
                        type: image
                        image_url: https://cdn.uphex.com/templates/999.jpg
                    ads:
                      - id: 777
                        position: 0
                        headline: Move without pain
                        primary_text: Book a simple consult for disc pain.
                    adsets:
                      - id: 888
                        position: 0
                        name: Warm Audience
                    created_at: "2026-07-24T18:00:00Z"
                    updated_at: "2026-07-24T18:30:00Z"
                meta:
                  resource: templates
                  page: 1
                  per_page: 25
                  total_entries: 40
                  total_pages: 2
        "400":
          description: Query parameters failed API-edge validation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: The API credential is missing `templates:read`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: CMS service unavailable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "504":
          description: CMS service timed out.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
    post:
      summary: Create API draft template
      tags: [Templates]
      description: |
        Requires `templates:write`. Creates a CMS-owned API draft template for
        the resolved agency and returns the same public-safe template envelope
        used by read endpoints. The draft is not launch-ready. Provider tokens,
        raw metadata, prompt data, audit internals, and CMS-only support fields
        are not part of the public contract.

        Creative write beta guarantee: this route creates draft/API-created
        templates only. It does not launch, publish, activate, or call
        CampaignBuilders/backend campaign launch endpoints. Every accepted or
        rejected write is request-logged with `X-Request-ID`, resolved agency,
        auth mode, downstream CMS status, rate-limit state, idempotency state
        when present, and public caller attribution forwarded to CMS audit paths.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - in: header
          name: Idempotency-Key
          schema:
            type: string
            maxLength: 255
            pattern: "^[A-Za-z0-9._:-]+$"
          description: |
            Optional retry-safety key for API key, legacy bearer-token, and
            OAuth access token requests. API-key keys are scoped to the API
            key; OAuth keys are scoped to the OAuth client and the token's
            agency, so refreshed tokens of the same client share them.
            Reusing the same key with the same payload replays the original
            public response and returns `X-Idempotent-Replay: true`. Reusing a
            key with a different payload returns `409`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [name, platform]
              properties:
                name:
                  type: string
                  maxLength: 255
                platform:
                  type: string
                  enum: [facebook, google]
                category_name:
                  type: string
                  maxLength: 255
                category_id:
                  type: integer
                  minimum: 1
                headline:
                  type: string
                  maxLength: 255
                body:
                  type: string
                  maxLength: 10000
                link_description:
                  type: string
                  maxLength: 255
                call_to_action_button_text:
                  type: string
                  maxLength: 255
                dynamic_creative:
                  type: boolean
                conversion_event_type:
                  type: string
                  maxLength: 255
                ad_structure:
                  type: string
                  maxLength: 255
                ad_format:
                  type: string
                  maxLength: 255
                is_public:
                  type: boolean
                requires_end_date:
                  type: boolean
                business_type:
                  type: string
                  enum: [b2b, b2c]
                offer_type:
                  type: string
                  enum: [direct_response, lead_magnet]
                utm_parameters:
                  type: string
                  maxLength: 10000
                background_image_source:
                  type: string
                  enum: [ad_image, custom_image]
                ghl_custom_tags:
                  type: string
                  maxLength: 255
                workday_customer:
                  type: string
                  maxLength: 10000
                workday_product:
                  type: string
                  maxLength: 10000
                workday_offer:
                  type: string
                  maxLength: 10000
                meta:
                  type: object
                  additionalProperties: true
              example:
                name: Disc Rehab Draft
                platform: facebook
                headline: Move without pain
                body: Book a simple consult for disc pain.
      responses:
        "201":
          description: Draft template created.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
            X-Idempotent-Replay:
              $ref: "#/components/headers/IdempotentReplay"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicEnvelope"
        "400":
          description: Request body failed API-edge or CMS validation, or the Idempotency-Key is invalid.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: The API credential is missing `templates:write`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "409":
          description: Idempotency key is currently processing or was reused with a different request.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: CMS service unavailable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "504":
          description: CMS service timed out.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
  /v1/templates/{id}:
    get:
      summary: Get template creative preview
      tags: [Templates]
      description: |
        Requires `templates:read`. Returns one agency-scoped, read-only
        CMS-owned template preview. Cross-agency template ids return not found
        from CMS without exposing whether the template exists elsewhere.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - in: path
          name: id
          required: true
          schema:
            type: integer
            minimum: 1
            format: int64
          description: Bounded positive CMS template id.
      responses:
        "200":
          description: Normalized read-only template preview detail.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicEnvelope"
              example:
                data:
                  id: 12345
                  guid: template-guid
                  name: Disc Rehab Preview
                  platform: facebook
                  enabled: true
                  availability: available
                  creative:
                    headline: Move without pain
                    primary_text: Book a simple consult for disc pain.
                    body: Book a simple consult for disc pain.
                    link_description: See available times
                    call_to_action: LEARN_MORE
                  media:
                    - id: 999
                      type: image
                      image_url: https://cdn.uphex.com/templates/999.jpg
                  ads:
                    - id: 777
                      position: 0
                      name: Consult Ad
                      headline: Move without pain
                      primary_text: Book a simple consult for disc pain.
                      call_to_action: LEARN_MORE
                      media:
                        - id: 999
                          type: image
                          image_url: https://cdn.uphex.com/templates/999.jpg
                  adsets:
                    - id: 888
                      position: 0
                      name: Warm Audience
                      ad_ids: [777]
                meta:
                  resource: template
        "400":
          description: Template id or query parameters failed API-edge validation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: The API credential is missing `templates:read`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "404":
          description: Template was not found for the resolved agency.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: CMS service unavailable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "504":
          description: CMS service timed out.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
    patch:
      summary: Update API draft template
      tags: [Templates]
      description: |
        Requires `templates:write`. Updates an API-created draft template for
        the resolved agency. Cross-agency ids return not found without leaking
        existence. Launched templates, non-drafts, and UI-authored drafts return
        `409` because they are not writable through the public API.

        Creative write beta guarantee: this route updates draft/API-created
        templates only. It does not launch, publish, activate, or call
        CampaignBuilders/backend campaign launch endpoints. Update requests do
        not replay from idempotency storage; read the template or request log
        before retrying after an ambiguous client/network failure. Every write
        is request-logged with `X-Request-ID`, resolved agency, auth mode,
        downstream CMS status, and public caller attribution forwarded to CMS
        audit paths.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - in: path
          name: id
          required: true
          schema:
            type: integer
            minimum: 1
            format: int64
          description: Bounded positive CMS template id.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              minProperties: 1
              properties:
                name:
                  type: string
                  maxLength: 255
                category_id:
                  type: integer
                  minimum: 1
                headline:
                  type: string
                  maxLength: 255
                body:
                  type: string
                  maxLength: 10000
                link_description:
                  type: string
                  maxLength: 255
                call_to_action_button_text:
                  type: string
                  maxLength: 255
                dynamic_creative:
                  type: boolean
                conversion_event_type:
                  type: string
                  maxLength: 255
                ad_structure:
                  type: string
                  maxLength: 255
                ad_format:
                  type: string
                  maxLength: 255
                is_public:
                  type: boolean
                requires_end_date:
                  type: boolean
                business_type:
                  type: string
                  enum: [b2b, b2c]
                offer_type:
                  type: string
                  enum: [direct_response, lead_magnet]
                utm_parameters:
                  type: string
                  maxLength: 10000
                background_image_source:
                  type: string
                  enum: [ad_image, custom_image]
                ghl_custom_tags:
                  type: string
                  maxLength: 255
                workday_customer:
                  type: string
                  maxLength: 10000
                workday_product:
                  type: string
                  maxLength: 10000
                workday_offer:
                  type: string
                  maxLength: 10000
                meta:
                  type: object
                  additionalProperties: true
              example:
                headline: Move better this week
      responses:
        "200":
          description: Draft template updated.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicEnvelope"
        "400":
          description: Template id or request body failed validation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: The API credential is missing `templates:write`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "404":
          description: Template was not found for the resolved agency.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "409":
          description: Template is not an API-created draft writable through the public API.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: CMS service unavailable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "504":
          description: CMS service timed out.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
    delete:
      summary: Delete API draft template
      tags: [Templates]
      description: |
        Requires `templates:write`. Soft-deletes an API-created draft template
        for the resolved agency. Cross-agency ids return not found without
        leaking existence. Launched templates, non-drafts, and UI-authored
        drafts return `409`.

        Creative write beta guarantee: delete means soft-delete of an API-created
        draft and is the rollback path for removing beta-created draft assets
        without affecting launched campaigns. This route does not launch,
        publish, activate, hard-delete downstream campaign assets, or call
        CampaignBuilders/backend campaign launch endpoints. Delete requests do
        not replay from idempotency storage; read the template or request log
        before retrying after an ambiguous client/network failure. Every write
        is request-logged with `X-Request-ID`, resolved agency, auth mode,
        downstream CMS status, and public caller attribution forwarded to CMS
        audit paths.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - in: path
          name: id
          required: true
          schema:
            type: integer
            minimum: 1
            format: int64
          description: Bounded positive CMS template id.
      responses:
        "200":
          description: Draft template soft-deleted.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicEnvelope"
              example:
                data:
                  id: 12345
                  deleted: true
                  deleted_at: "2026-07-28T01:00:00Z"
                meta:
                  resource: template
        "400":
          description: Template id failed API-edge validation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: The API credential is missing `templates:write`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "404":
          description: Template was not found for the resolved agency.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "409":
          description: Template is not an API-created draft writable through the public API.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: CMS service unavailable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "504":
          description: CMS service timed out.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
  /v1/templates/{id}/images:
    post:
      summary: Upload image to API draft template
      tags: [Templates]
      description: |
        Requires `templates:write`. Attaches image media to an API-created draft
        template for the resolved agency and returns the same public-safe
        template envelope used by read and write endpoints. Cross-agency ids
        return not found without leaking existence. Launched templates,
        non-drafts, and UI-authored drafts return `409`.

        Provide exactly one of `image_url` or `image_base64`. `image_url` must
        be HTTPS. `image_base64` may be raw base64 or a data URL declaring one
        of `image/jpeg`, `image/png`, `image/gif`, or `image/svg+xml`; decoded
        images have a 10 MB decoded image limit. This endpoint has a 15 MB route-specific
        request body allowance so encoded images can fit, while other public
        API, OAuth, and MCP request bodies remain limited to 1 MB.

        Creative write beta guarantee: this route attaches media to draft/API-created
        templates only. It does not launch, publish, activate, or call
        CampaignBuilders/backend campaign launch endpoints. Every accepted or
        rejected write is request-logged with `X-Request-ID`, resolved agency,
        auth mode, downstream CMS status, rate-limit state, idempotency state
        when present, and public caller attribution forwarded to CMS audit paths.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - in: path
          name: id
          required: true
          schema:
            type: integer
            minimum: 1
            format: int64
          description: Bounded positive CMS template id.
        - in: header
          name: Idempotency-Key
          schema:
            type: string
            maxLength: 255
            pattern: "^[A-Za-z0-9._:-]+$"
          description: |
            Optional retry-safety key for API key, legacy bearer-token, and
            OAuth access token requests. API-key keys are scoped to the API
            key; OAuth keys are scoped to the OAuth client and the token's
            agency, so refreshed tokens of the same client share them.
            Reusing the same key with the same payload replays the original
            public response and returns `X-Idempotent-Replay: true`. Reusing a
            key with a different payload returns `409`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: object
                  additionalProperties: false
                  required: [image_url]
                  properties:
                    image_url:
                      type: string
                      format: uri
                      maxLength: 10000
                      pattern: "^https://"
                    template_ad_id:
                      type: integer
                      minimum: 1
                    image_orientation:
                      type: string
                      enum: [square, landscape]
                - type: object
                  additionalProperties: false
                  required: [image_base64]
                  properties:
                    image_base64:
                      type: string
                      description: Raw base64 or data URL. Declared MIME, when present, must be image/jpeg, image/png, image/gif, or image/svg+xml.
                      maxLength: 15000000
                    template_ad_id:
                      type: integer
                      minimum: 1
                    image_orientation:
                      type: string
                      enum: [square, landscape]
            examples:
              urlImage:
                summary: HTTPS image URL
                value:
                  image_url: https://cdn.example.com/template-image.png
                  image_orientation: square
              base64Image:
                summary: Base64 PNG image
                value:
                  image_base64: data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB
                  template_ad_id: 777
              base64SvgImage:
                summary: Base64 SVG image
                value:
                  image_base64: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=
                  image_orientation: square
      responses:
        "200":
          description: Draft template image attached.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
            X-Idempotent-Replay:
              $ref: "#/components/headers/IdempotentReplay"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicEnvelope"
              example:
                data:
                  id: 12345
                  name: Disc Rehab Draft
                  enabled: false
                  availability: disabled
                  media:
                    - id: 999
                      type: image
                      image_url: https://cdn.uphex.com/templates/999.jpg
                      image_orientation: square
                meta:
                  resource: template
        "400":
          description: Template id or image request body failed API-edge or CMS validation, or the Idempotency-Key is invalid.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: The API credential is missing `templates:write`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "404":
          description: Template was not found for the resolved agency.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "409":
          description: Template is not an API-created draft writable through the public API, or an idempotency key is currently processing or was reused with a different request.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "413":
          description: Request body exceeds the template image upload 15 MB route limit.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: CMS service unavailable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "504":
          description: CMS service timed out.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
  /v1/campaigns:
    get:
      summary: List campaigns
      tags: [Campaigns]
      description: |
        Requires `campaigns:read`. Campaign building and campaign storage remain
        backend-owned; this edge validates the request, resolves agency policy,
        and proxies to backend with the matching backend JWT.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - in: query
          name: client_id
          schema:
            type: integer
            minimum: 1
        - in: query
          name: status
          schema:
            type: string
            enum: [ACTIVE, ENABLED, PAUSED, IN_PROCESS, ARCHIVED]
        - in: query
          name: provider
          schema:
            type: string
            enum: [facebook, google]
        - in: query
          name: page
          schema:
            type: integer
            minimum: 1
            default: 1
      responses:
        "200":
          description: Normalized data envelope delegated from the backend service.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicEnvelope"
        "400":
          description: Query parameters failed API-edge validation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: The API credential is missing `campaigns:read`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: Backend service unavailable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "504":
          description: Backend service timed out.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
  /v1/campaigns/{campaign_id}/creatives:
    get:
      summary: List live campaign creative previews
      tags: [Campaigns]
      description: |
        Requires `creatives:read`. Returns read-only, cache-only live campaign
        ad creative previews for client portals. Campaign and creative cache
        data remain backend-owned; this API edge validates the request, resolves
        agency policy, and proxies to backend with the matching backend JWT.

        The backend only reads cached campaign/adset/ad/creative rows and
        never triggers provider syncs, backfills, or Facebook/Google calls from this
        public GET route. Missing and stale cache are represented explicitly in
        `meta.cache_state`, `meta.stale`, and each row's `cache` object instead
        of returning fake creative data. Cross-agency ids and wrong-client campaign ids return not found
        without revealing whether the campaign exists
        elsewhere. Provider tokens, raw targeting, object story specs, asset feed specs,
        review feedback, and provider internals are not exposed.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - in: path
          name: campaign_id
          required: true
          schema:
            type: integer
            minimum: 1
            format: int64
          description: Bounded positive backend campaign id.
        - in: query
          name: client_id
          schema:
            type: integer
            minimum: 1
          description: Optional owning client filter. Wrong-client campaign ids return not found.
      responses:
        "200":
          description: Normalized cache-only creative preview envelope.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicEnvelope"
              examples:
                fresh:
                  summary: Fresh cached creative preview
                  value:
                    data:
                      - campaign:
                          id: 456
                          pid: camp_456
                          name: Summer Campaign
                          provider: facebook
                          status: ACTIVE
                          objective: LEADS
                          client_id: 123
                        adset:
                          id: adset_1
                          name: Warm Audience
                          status: ACTIVE
                          effective_status: ACTIVE
                        ad:
                          id: ad_1
                          name: Main Ad
                          status: ACTIVE
                          effective_status: ACTIVE
                          preview_shareable_link: https://fb.example/ad-preview
                        creative:
                          id: creative_1
                          name: null
                          status: null
                          effective_status: null
                        media:
                          image_url: https://cdn.example/image.jpg
                          video_thumbnail_url: null
                          video_id: null
                          reference: image_hash_123
                        copy:
                          primary_text: Book a consult today.
                          headline: Move better
                          link_description: Care that fits your schedule
                          link: https://example.com/book
                        cta:
                          type: LEARN_MORE
                          value:
                            link: https://example.com/book
                        status:
                          campaign: ACTIVE
                          adset: ACTIVE
                          adset_effective: ACTIVE
                          ad: ACTIVE
                          ad_effective: ACTIVE
                          creative: null
                          creative_effective: null
                        cache:
                          state: fresh
                          stale: false
                          campaign_cache_status: fresh
                          campaign_cached_at: "2026-07-24T12:00:00Z"
                          updated_at: "2026-07-24T12:00:01"
                    meta:
                      resource: campaign_creatives
                      campaign_id: 456
                      client_id: 123
                      provider: facebook
                      cache_status: fresh
                      cache_state: fresh
                      cached_at: "2026-07-24T12:00:00Z"
                      stale: false
                      preview_count: 1
                missingCache:
                  summary: Campaign exists but creative cache is missing
                  value:
                    data: []
                    meta:
                      resource: campaign_creatives
                      campaign_id: 456
                      client_id: 123
                      provider: facebook
                      cache_status: stale
                      cache_state: missing
                      cached_at: null
                      stale: true
                      preview_count: 0
        "400":
          description: Campaign id or query parameters failed API-edge validation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: The API credential is missing `creatives:read`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "404":
          description: Campaign was not found for the authenticated agency and optional client filter.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: Backend service unavailable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "504":
          description: Backend service timed out.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
  /v1/leads:
    get:
      summary: List leads
      tags: [Leads]
      description: |
        Requires `leads:read`. Lists a client's leads from UpHex's CRM (the same
        leads shown in the CRM inbox, pipelines, and workflows). `client_id` is
        required. `search` is a case-insensitive partial match on name, email, or
        phone; `email` is an exact (case-insensitive) match. Lead ids are CRM ids:
        ids returned before the October 2026 move to the CRM are not valid.

        **Incremental sync.** Send `updated_since` to list only leads changed at
        or after that time, oldest change first (`updated_at` ascending, then
        `id`). Results are keyset-paginated: pass `meta.next_cursor` back as
        `cursor` (keeping `updated_since` and the other filters) until
        `next_cursor` is `null`. `page` cannot be combined with `updated_since`
        or `cursor`, and sync responses carry `next_cursor` and `has_more`
        instead of page totals. A lead updated while you page moves to the end
        and is returned again, so upsert by `id`. Use this as a backstop to
        webhooks: store the largest `updated_at` you processed and poll from a
        few minutes before it. Removed leads are not returned.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - $ref: "#/components/parameters/CrmClientId"
        - in: query
          name: search
          schema:
            type: string
            maxLength: 255
        - in: query
          name: email
          schema:
            type: string
            maxLength: 255
        - in: query
          name: status
          schema:
            type: string
            maxLength: 255
        - in: query
          name: tag
          description: Only leads with this tag name.
          schema:
            type: string
            maxLength: 255
        - in: query
          name: pipeline_id
          schema:
            type: integer
            minimum: 1
        - in: query
          name: pipeline_stage_id
          schema:
            type: integer
            minimum: 1
        - in: query
          name: campaign_id
          schema:
            type: integer
            minimum: 1
        - in: query
          name: updated_since
          description: |
            Only leads whose `updated_at` is at or after this time (inclusive,
            second precision). ISO 8601 datetime with an offset; a datetime
            without an offset is read as UTC and a bare date as midnight UTC.
            Switches the list to keyset pagination ordered by `updated_at`, then
            `id`. Percent-encode a `+` offset as `%2B` (or send `Z`). If
            incremental sync is unavailable downstream the request fails with
            `502` `incremental_sync_unavailable` rather than returning an
            unfiltered page.
          schema:
            type: string
            example: "2026-10-22T18:30:00Z"
        - in: query
          name: cursor
          description: |
            Opaque `meta.next_cursor` from the previous incremental-sync page.
            Send it with the same `updated_since` and filters. Not combinable
            with `page`.
          schema:
            type: string
            maxLength: 512
            pattern: "^[A-Za-z0-9_-]+$"
        - in: query
          name: page
          description: Page number for the default list. Not combinable with `updated_since` or `cursor`.
          schema:
            type: integer
            minimum: 1
            default: 1
        - in: query
          name: per_page
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
      responses:
        "200":
          description: 'Page of `Lead` objects. The default list reports `page`, `per_page`, `total_entries`, and `total_pages` in `meta`; with `updated_since` or `cursor` it reports `per_page`, `next_cursor`, and `has_more`.'
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Lead"
                  meta:
                    $ref: "#/components/schemas/PublicMeta"
        "400":
          description: 'Query parameters or body failed API-edge validation (including a missing `client_id`), or `X-Agency-Id` is invalid.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: 'The API credential is missing `leads:read`, or the app may not access the agency.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "404":
          description: '`client_not_found` for a `client_id` outside the authorized agency.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: 'CRM service unavailable.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "504":
          description: 'CRM service timed out.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
    post:
      summary: Create lead
      tags: [Leads]
      description: |
        Requires `leads:write`. Creates one lead in UpHex's CRM for `client_id`
        (`201`). With `match: "email_then_phone"`, returns the client's existing lead
        with the same email, or else the same phone, as `200` with
        `meta.matched: true`, and creates one only when there is no match
        (`201`, `meta.matched: false`). Either `email` or `phone` is required.
        Leads created here have `source` `public_api`. UpHex's CRM sends
        `lead.created` once per created lead; the API edge does not emit it, so a
        match or an idempotent replay sends no event. Bulk creation is not
        supported.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [client_id]
              properties:
                client_id:
                  type: integer
                  minimum: 1
                  description: Client that owns the lead. Must belong to the authorized agency.
                full_name:
                  type: string
                  maxLength: 255
                email:
                  type: string
                  maxLength: 255
                  format: email
                phone:
                  type: string
                  maxLength: 255
                status:
                  type: string
                  maxLength: 255
                  description: CRM status name, for example `New`.
                campaign_id:
                  type: integer
                  minimum: 1
                campaign_name:
                  type: string
                  maxLength: 255
                metadata:
                  type: object
                  maxProperties: 100
                  additionalProperties: true
                match:
                  type: string
                  enum: [email_then_phone]
            example:
              client_id: 123
              full_name: Ada Lovelace
              email: ada@example.com
              phone: "555-0100"
              match: email_then_phone
      responses:
        "201":
          description: 'The created lead.'
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
            X-Idempotent-Replay:
              $ref: "#/components/headers/IdempotentReplay"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    $ref: "#/components/schemas/Lead"
                  meta:
                    $ref: "#/components/schemas/PublicMeta"
        "200":
          description: '`match` found an existing lead (`meta.matched: true`).'
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
            X-Idempotent-Replay:
              $ref: "#/components/headers/IdempotentReplay"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    $ref: "#/components/schemas/Lead"
                  meta:
                    $ref: "#/components/schemas/PublicMeta"
        "400":
          description: 'Query parameters or body failed API-edge validation (including a missing `client_id`), or `X-Agency-Id` is invalid.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: 'The API credential is missing `leads:write`, or the app may not access the agency.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "404":
          description: '`client_not_found` for a `client_id` outside the authorized agency.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "409":
          description: 'Idempotency key is currently processing or was reused with a different request.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "413":
          description: 'Request body exceeds the API edge 1 MB limit.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "422":
          description: 'The CRM rejected the write (for example an invalid stage, duplicate contact, or channel without an address). `errors[].detail` explains why.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: 'CRM service unavailable.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "504":
          description: 'CRM service timed out.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
  /v1/leads/lookup:
    get:
      summary: Look up lead by email or phone
      tags: [Leads]
      description: |
        Requires `leads:read`. Exact match within `client_id`: by `email` first
        (case-insensitive), then by `phone` (format-agnostic national number). Never
        fuzzy. At least one of `email` or `phone` is required. Returns `404` when no
        lead matches.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - $ref: "#/components/parameters/CrmClientId"
        - in: query
          name: email
          schema:
            type: string
            maxLength: 255
        - in: query
          name: phone
          schema:
            type: string
            maxLength: 255
      responses:
        "200":
          description: 'The matched lead.'
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    $ref: "#/components/schemas/Lead"
                  meta:
                    $ref: "#/components/schemas/PublicMeta"
        "400":
          description: 'Query parameters or body failed API-edge validation (including a missing `client_id`), or `X-Agency-Id` is invalid.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: 'The API credential is missing `leads:read`, or the app may not access the agency.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "404":
          description: '`client_not_found` for a `client_id` outside the authorized agency, or the lead does not exist for that client.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: 'CRM service unavailable.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "504":
          description: 'CRM service timed out.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
  /v1/leads/{id}:
    get:
      summary: Get lead
      tags: [Leads]
      description: |
        Requires `leads:read`. Returns one CRM lead for `client_id`, including
        `metadata`.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - $ref: "#/components/parameters/LeadId"
        - $ref: "#/components/parameters/CrmClientId"
      responses:
        "200":
          description: 'The lead.'
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    $ref: "#/components/schemas/Lead"
                  meta:
                    $ref: "#/components/schemas/PublicMeta"
        "400":
          description: 'Query parameters or body failed API-edge validation (including a missing `client_id`), or `X-Agency-Id` is invalid.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: 'The API credential is missing `leads:read`, or the app may not access the agency.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "404":
          description: '`client_not_found` for a `client_id` outside the authorized agency, or the lead does not exist for that client.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: 'CRM service unavailable.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "504":
          description: 'CRM service timed out.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
    patch:
      summary: Update lead
      tags: [Leads]
      description: |
        Requires `leads:write`. Updates `full_name`, `email`, `phone`, `status`, and
        `metadata` (replaced as a whole). `pipeline_stage_id` moves the lead to that
        stage (add `pipeline_id` to switch pipelines); the stage move runs before the
        field update. Send at least one field besides `client_id`.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - $ref: "#/components/parameters/LeadId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [client_id]
              properties:
                client_id:
                  type: integer
                  minimum: 1
                  description: Client that owns the lead. Must belong to the authorized agency.
                full_name:
                  type: string
                  maxLength: 255
                email:
                  type: string
                  maxLength: 255
                  format: email
                phone:
                  type: string
                  maxLength: 255
                status:
                  type: string
                  maxLength: 255
                metadata:
                  type: object
                  maxProperties: 100
                  additionalProperties: true
                pipeline_id:
                  type: integer
                  minimum: 1
                  description: Requires `pipeline_stage_id`.
                pipeline_stage_id:
                  type: integer
                  minimum: 1
            example:
              client_id: 123
              status: In Progress
              pipeline_stage_id: 42
      responses:
        "200":
          description: 'The updated lead.'
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    $ref: "#/components/schemas/Lead"
                  meta:
                    $ref: "#/components/schemas/PublicMeta"
        "400":
          description: 'Query parameters or body failed API-edge validation (including a missing `client_id`), or `X-Agency-Id` is invalid.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: 'The API credential is missing `leads:write`, or the app may not access the agency.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "404":
          description: '`client_not_found` for a `client_id` outside the authorized agency, or the lead does not exist for that client.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "413":
          description: 'Request body exceeds the API edge 1 MB limit.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "422":
          description: 'The CRM rejected the write (for example an invalid stage, duplicate contact, or channel without an address). `errors[].detail` explains why.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: 'CRM service unavailable.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "504":
          description: 'CRM service timed out.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
  /v1/leads/{id}/pipeline_stage:
    patch:
      summary: Move lead to a pipeline stage
      tags: [Leads]
      description: |
        Requires `leads:write`. Moves the lead to `pipeline_stage_id`, a stage id from
        `GET /v1/pipelines`. Add `pipeline_id` to move the lead into another pipeline.
        A stage that is not available for the lead returns `422`.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - $ref: "#/components/parameters/LeadId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [client_id, pipeline_stage_id]
              properties:
                client_id:
                  type: integer
                  minimum: 1
                  description: Client that owns the lead. Must belong to the authorized agency.
                pipeline_id:
                  type: integer
                  minimum: 1
                pipeline_stage_id:
                  type: integer
                  minimum: 1
      responses:
        "200":
          description: 'The updated lead.'
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    $ref: "#/components/schemas/Lead"
                  meta:
                    $ref: "#/components/schemas/PublicMeta"
        "400":
          description: 'Query parameters or body failed API-edge validation (including a missing `client_id`), or `X-Agency-Id` is invalid.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: 'The API credential is missing `leads:write`, or the app may not access the agency.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "404":
          description: '`client_not_found` for a `client_id` outside the authorized agency, or the lead does not exist for that client.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "413":
          description: 'Request body exceeds the API edge 1 MB limit.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "422":
          description: 'The CRM rejected the write (for example an invalid stage, duplicate contact, or channel without an address). `errors[].detail` explains why.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: 'CRM service unavailable.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "504":
          description: 'CRM service timed out.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
  /v1/leads/{id}/tags:
    post:
      summary: Add tags to lead
      tags: [Leads]
      description: |
        Requires `leads:write`. Adds tags by name, creating any tag the client does
        not have yet. Tags already on the lead are left alone. Tag changes sync to a
        connected GoHighLevel location through the CRM. Returns the updated lead.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - $ref: "#/components/parameters/LeadId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [client_id, tags]
              properties:
                client_id:
                  type: integer
                  minimum: 1
                  description: Client that owns the lead. Must belong to the authorized agency.
                tags:
                  type: array
                  minItems: 1
                  maxItems: 50
                  items:
                    type: string
                    minLength: 1
                    maxLength: 100
            example:
              client_id: 123
              tags: [hot-lead, closebot]
      responses:
        "200":
          description: 'The updated lead.'
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    $ref: "#/components/schemas/Lead"
                  meta:
                    $ref: "#/components/schemas/PublicMeta"
        "400":
          description: 'Query parameters or body failed API-edge validation (including a missing `client_id`), or `X-Agency-Id` is invalid.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: 'The API credential is missing `leads:write`, or the app may not access the agency.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "404":
          description: '`client_not_found` for a `client_id` outside the authorized agency, or the lead does not exist for that client.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "413":
          description: 'Request body exceeds the API edge 1 MB limit.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "422":
          description: 'The CRM rejected the write (for example an invalid stage, duplicate contact, or channel without an address). `errors[].detail` explains why.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: 'CRM service unavailable.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "504":
          description: 'CRM service timed out.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
  /v1/leads/{id}/tags/{tag}:
    delete:
      summary: Remove tag from lead
      tags: [Leads]
      description: |
        Requires `leads:write`. Removes the tag named `tag` (URL-encoded,
        case-insensitive) from the lead. Returns `404` when the client has no tag with
        that name. Returns the updated lead.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - $ref: "#/components/parameters/LeadId"
        - in: path
          name: tag
          required: true
          schema:
            type: string
            maxLength: 100
        - $ref: "#/components/parameters/CrmClientId"
      responses:
        "200":
          description: 'The updated lead.'
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    $ref: "#/components/schemas/Lead"
                  meta:
                    $ref: "#/components/schemas/PublicMeta"
        "400":
          description: 'Query parameters or body failed API-edge validation (including a missing `client_id`), or `X-Agency-Id` is invalid.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: 'The API credential is missing `leads:write`, or the app may not access the agency.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "404":
          description: '`client_not_found` for a `client_id` outside the authorized agency, or the lead does not exist for that client.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: 'CRM service unavailable.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "504":
          description: 'CRM service timed out.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
  /v1/leads/{id}/notes:
    get:
      summary: List lead notes
      tags: [Leads]
      description: |
        Requires `leads:read`. Notes on the lead, newest first.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - $ref: "#/components/parameters/LeadId"
        - $ref: "#/components/parameters/CrmClientId"
      responses:
        "200":
          description: 'The lead''s notes.'
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/LeadNote"
                  meta:
                    $ref: "#/components/schemas/PublicMeta"
        "400":
          description: 'Query parameters or body failed API-edge validation (including a missing `client_id`), or `X-Agency-Id` is invalid.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: 'The API credential is missing `leads:read`, or the app may not access the agency.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "404":
          description: '`client_not_found` for a `client_id` outside the authorized agency, or the lead does not exist for that client.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: 'CRM service unavailable.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "504":
          description: 'CRM service timed out.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
    post:
      summary: Add lead note
      tags: [Leads]
      description: |
        Requires `leads:write`. Adds a note authored as your developer app
        (`author_type: system`, `author_name`: the app name) and records a
        `note_added` activity on the lead.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - $ref: "#/components/parameters/LeadId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [client_id, content]
              properties:
                client_id:
                  type: integer
                  minimum: 1
                  description: Client that owns the lead. Must belong to the authorized agency.
                content:
                  type: string
                  maxLength: 10000
            example:
              client_id: 123
              content: Asked about pricing; follow up Tuesday.
      responses:
        "201":
          description: 'The created note.'
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
            X-Idempotent-Replay:
              $ref: "#/components/headers/IdempotentReplay"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    $ref: "#/components/schemas/LeadNote"
                  meta:
                    $ref: "#/components/schemas/PublicMeta"
        "400":
          description: 'Query parameters or body failed API-edge validation (including a missing `client_id`), or `X-Agency-Id` is invalid.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: 'The API credential is missing `leads:write`, or the app may not access the agency.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "404":
          description: '`client_not_found` for a `client_id` outside the authorized agency, or the lead does not exist for that client.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "409":
          description: 'Idempotency key is currently processing or was reused with a different request.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "413":
          description: 'Request body exceeds the API edge 1 MB limit.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "422":
          description: 'The CRM rejected the write (for example an invalid stage, duplicate contact, or channel without an address). `errors[].detail` explains why.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: 'CRM service unavailable.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "504":
          description: 'CRM service timed out.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
  /v1/leads/{id}/activities:
    post:
      summary: Log lead activity
      tags: [Leads]
      description: |
        Requires `leads:write`. Adds a `call` or `email` entry to the lead's activity
        timeline. `occurred_at` defaults to now.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - $ref: "#/components/parameters/LeadId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [client_id, activity_type, description]
              properties:
                client_id:
                  type: integer
                  minimum: 1
                  description: Client that owns the lead. Must belong to the authorized agency.
                activity_type:
                  type: string
                  enum: [call, email]
                description:
                  type: string
                  maxLength: 2000
                metadata:
                  type: object
                  maxProperties: 100
                  additionalProperties: true
                occurred_at:
                  type: string
                  format: date-time
      responses:
        "201":
          description: 'The created activity.'
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
            X-Idempotent-Replay:
              $ref: "#/components/headers/IdempotentReplay"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    $ref: "#/components/schemas/LeadActivity"
                  meta:
                    $ref: "#/components/schemas/PublicMeta"
        "400":
          description: 'Query parameters or body failed API-edge validation (including a missing `client_id`), or `X-Agency-Id` is invalid.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: 'The API credential is missing `leads:write`, or the app may not access the agency.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "404":
          description: '`client_not_found` for a `client_id` outside the authorized agency, or the lead does not exist for that client.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "409":
          description: 'Idempotency key is currently processing or was reused with a different request.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "413":
          description: 'Request body exceeds the API edge 1 MB limit.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "422":
          description: 'The CRM rejected the write (for example an invalid stage, duplicate contact, or channel without an address). `errors[].detail` explains why.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: 'CRM service unavailable.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "504":
          description: 'CRM service timed out.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
  /v1/leads/{id}/contact_channels:
    get:
      summary: Get lead contact channel preferences
      tags: [Leads]
      description: |
        Requires `leads:read`. Per-channel do-not-disturb (opt-out) state for the
        lead's email address and phone number.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - $ref: "#/components/parameters/LeadId"
        - $ref: "#/components/parameters/CrmClientId"
      responses:
        "200":
          description: 'Channel preferences.'
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/ContactChannel"
                  meta:
                    $ref: "#/components/schemas/PublicMeta"
        "400":
          description: 'Query parameters or body failed API-edge validation (including a missing `client_id`), or `X-Agency-Id` is invalid.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: 'The API credential is missing `leads:read`, or the app may not access the agency.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "404":
          description: '`client_not_found` for a `client_id` outside the authorized agency, or the lead does not exist for that client.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: 'CRM service unavailable.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "504":
          description: 'CRM service timed out.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
    patch:
      summary: Set lead do-not-disturb for a channel
      tags: [Leads]
      description: |
        Requires `leads:write`. `suppressed: true` opts the lead out of `channel`
        (do not disturb); `false` opts back in. SMS opt-ins follow the client's A2P
        consent rules. A channel the lead has no address for returns `422`.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - $ref: "#/components/parameters/LeadId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [client_id, channel, suppressed]
              properties:
                client_id:
                  type: integer
                  minimum: 1
                  description: Client that owns the lead. Must belong to the authorized agency.
                channel:
                  type: string
                  enum: [email, sms, marketing_sms, transactional_sms]
                suppressed:
                  type: boolean
      responses:
        "200":
          description: 'The updated channel preference.'
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    $ref: "#/components/schemas/ContactChannel"
                  meta:
                    $ref: "#/components/schemas/PublicMeta"
        "400":
          description: 'Query parameters or body failed API-edge validation (including a missing `client_id`), or `X-Agency-Id` is invalid.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: 'The API credential is missing `leads:write`, or the app may not access the agency.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "404":
          description: '`client_not_found` for a `client_id` outside the authorized agency, or the lead does not exist for that client.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "413":
          description: 'Request body exceeds the API edge 1 MB limit.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "422":
          description: 'The CRM rejected the write (for example an invalid stage, duplicate contact, or channel without an address). `errors[].detail` explains why.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: 'CRM service unavailable.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "504":
          description: 'CRM service timed out.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
  /v1/tags:
    get:
      summary: List tags
      tags: [Leads]
      description: |
        Requires `leads:read`. Tags available to `client_id`'s leads, including
        agency-wide tags.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - $ref: "#/components/parameters/CrmClientId"
      responses:
        "200":
          description: 'Tags.'
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Tag"
                  meta:
                    $ref: "#/components/schemas/PublicMeta"
        "400":
          description: 'Query parameters or body failed API-edge validation (including a missing `client_id`), or `X-Agency-Id` is invalid.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: 'The API credential is missing `leads:read`, or the app may not access the agency.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "404":
          description: '`client_not_found` for a `client_id` outside the authorized agency.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: 'CRM service unavailable.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "504":
          description: 'CRM service timed out.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
    post:
      summary: Create tag
      tags: [Leads]
      description: |
        Requires `leads:write`. Finds or creates a client tag by name (`201` either
        way).
      parameters:
        - $ref: "#/components/parameters/AgencyId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [client_id, name]
              properties:
                client_id:
                  type: integer
                  minimum: 1
                  description: Client that owns the lead. Must belong to the authorized agency.
                name:
                  type: string
                  maxLength: 100
      responses:
        "201":
          description: 'The tag.'
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    $ref: "#/components/schemas/Tag"
                  meta:
                    $ref: "#/components/schemas/PublicMeta"
        "400":
          description: 'Query parameters or body failed API-edge validation (including a missing `client_id`), or `X-Agency-Id` is invalid.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: 'The API credential is missing `leads:write`, or the app may not access the agency.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "404":
          description: '`client_not_found` for a `client_id` outside the authorized agency.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "413":
          description: 'Request body exceeds the API edge 1 MB limit.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "422":
          description: 'The CRM rejected the write (for example an invalid stage, duplicate contact, or channel without an address). `errors[].detail` explains why.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: 'CRM service unavailable.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "504":
          description: 'CRM service timed out.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
  /v1/pipelines:
    get:
      summary: List pipelines
      tags: [Leads]
      description: |
        Requires `leads:read`. Pipelines available to `client_id`, each with the
        stages a lead can be moved to (`PATCH /v1/leads/{id}/pipeline_stage`).
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - $ref: "#/components/parameters/CrmClientId"
      responses:
        "200":
          description: 'Pipelines with stages.'
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Pipeline"
                  meta:
                    $ref: "#/components/schemas/PublicMeta"
        "400":
          description: 'Query parameters or body failed API-edge validation (including a missing `client_id`), or `X-Agency-Id` is invalid.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: 'The API credential is missing `leads:read`, or the app may not access the agency.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "404":
          description: '`client_not_found` for a `client_id` outside the authorized agency.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: 'CRM service unavailable.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "504":
          description: 'CRM service timed out.'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
  /v1/conversations:
    get:
      summary: List conversations
      tags: [Conversations]
      description: |
        Requires `conversations:read`. Lists a client's actionable conversations
        from crm-service: the lead's latest message is inbound, or outbound
        within the last 72 hours, or the lead has no messages and was created
        within 72 hours. `client_id` must belong to the authorized agency for
        every credential type.

        Cursor pagination: follow `meta.next_cursor` until it is `null`.
        `updated_since` filters each page by `updated_at`, so a page can hold
        fewer than `limit` items; keep following `next_cursor`.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - $ref: "#/components/parameters/CrmClientId"
        - in: query
          name: updated_since
          schema:
            type: string
            format: date-time
          description: ISO 8601 timestamp with a UTC offset. Keeps conversations whose `updated_at` is at or after it.
        - $ref: "#/components/parameters/MessagingCursor"
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
      responses:
        "200":
          description: One page of conversations.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Conversation"
                  meta:
                    $ref: "#/components/schemas/CursorMeta"
        "400":
          $ref: "#/components/responses/MessagingInvalid"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/MessagingForbidden"
        "404":
          $ref: "#/components/responses/MessagingNotFound"
        "429":
          $ref: "#/components/responses/MessagingRateLimited"
        "502":
          $ref: "#/components/responses/MessagingUnavailable"
        "504":
          $ref: "#/components/responses/MessagingTimeout"
  /v1/leads/{id}/messages:
    get:
      summary: List a lead's messages
      tags: [Conversations]
      description: |
        Requires `conversations:read`. Returns the lead's SMS, email, voice,
        and custom-channel messages newest first, from crm-service. History
        reaches the 500 most recent messages. `external_id` is shown only for
        custom messages your app logged with one.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - $ref: "#/components/parameters/LeadId"
        - $ref: "#/components/parameters/CrmClientId"
        - in: query
          name: channel
          schema:
            type: string
            enum: [sms, email, voice, custom]
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
        - $ref: "#/components/parameters/MessagingCursor"
      responses:
        "200":
          description: One page of messages, newest first.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/LeadMessage"
                  meta:
                    $ref: "#/components/schemas/CursorMeta"
        "400":
          $ref: "#/components/responses/MessagingInvalid"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/MessagingForbidden"
        "404":
          $ref: "#/components/responses/MessagingNotFound"
        "429":
          $ref: "#/components/responses/MessagingRateLimited"
        "502":
          $ref: "#/components/responses/MessagingUnavailable"
        "504":
          $ref: "#/components/responses/MessagingTimeout"
    post:
      summary: Send or log a message
      tags: [Conversations]
      description: |
        Requires `messages:send` and an `Idempotency-Key` header, for API keys
        and OAuth tokens alike.

        * `type: sms` / `type: email` sends through UpHex messaging (Twilio /
          Mailgun) and records the outbound message on the lead.
        * `type: custom` only logs a message on a channel UpHex does not
          deliver. Inbound custom messages fire `contact_replied` workflows.

        Idempotency is held by crm-service, not the edge key store. For
        SMS/email the key is claimed before anything is sent: a repeat returns
        `200` with `meta.idempotent_replay: true` and `X-Idempotent-Replay:
        true` (a failed send replays the same error), `409`
        `idempotency_key_in_progress` while the first attempt runs, and `409`
        `idempotency_key_conflict` when the key was used for a different send.
        Custom messages dedupe on `external_id` when sent, otherwise on the
        key; a repeat whose stored message differs returns `409`
        `idempotency_key_conflict` (key) or `external_id_conflict` (your
        `external_id`). Keys and external ids are scoped to your app.

        A refused send keeps crm-service's code and HTTP status in the
        normalized error list (`errors[].code`, `source: crm`), with `reason`,
        `retryable`, and `lead_message_id` when known. Sends count against the
        app's per-minute rate limit (default 120).
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - $ref: "#/components/parameters/LeadId"
        - in: header
          name: Idempotency-Key
          required: true
          schema:
            type: string
            minLength: 1
            maxLength: 200
            pattern: "^[A-Za-z0-9._:-]+$"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LeadMessageCreateRequest"
            examples:
              sms:
                summary: Send an SMS
                value:
                  client_id: 123
                  type: sms
                  body: Hi Ada, are you free Tuesday at 3pm?
              email:
                summary: Send an email
                value:
                  client_id: 123
                  type: email
                  subject: Your quote
                  body: Hi Ada, your quote is below.
              custom:
                summary: Log a custom-channel message
                value:
                  client_id: 123
                  type: custom
                  direction: inbound
                  channel_label: CloseBot Chat
                  body: Can I book for Tuesday?
                  external_id: cb-msg-5521
                  occurred_at: "2026-10-22T15:04:05Z"
      responses:
        "201":
          description: Message sent (sms/email) or logged (custom).
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LeadMessageResult"
        "200":
          description: Idempotent replay of an earlier request with the same key.
          headers:
            X-Idempotent-Replay:
              $ref: "#/components/headers/IdempotentReplay"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LeadMessageResult"
        "400":
          description: Body failed API-edge validation (including a missing `client_id`), the `Idempotency-Key` header is missing or invalid, or `X-Agency-Id` is invalid.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: "`insufficient_credits`: the agency's messaging wallet balance is too low."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MessageSendErrors"
        "403":
          description: "`missing_scope` (`errors.base`), or `sms_not_enabled`: Phone & SMS is not on the agency's plan."
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/PublicErrors"
                  - $ref: "#/components/schemas/MessageSendErrors"
        "404":
          description: "`Client not found` for a `client_id` outside the authorized agency, or `client_not_found` / `lead_not_found` from crm-service."
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/PublicErrors"
                  - $ref: "#/components/schemas/MessageSendErrors"
        "409":
          description: "`ghl_routed_contact`, `idempotency_key_in_progress`, `idempotency_key_conflict`, or (custom) `external_id_conflict`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MessageSendErrors"
        "413":
          description: Request body exceeds the API edge 1 MB limit.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "422":
          description: |
            `invalid_params`, `lead_missing_phone`, `lead_missing_email`,
            `invalid_from_email`, `no_from_number_provisioned`,
            `a2p_campaign_not_ready`, `sms_suppressed`, `email_suppressed`,
            or `sending_domain_unavailable`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MessageSendErrors"
        "429":
          $ref: "#/components/responses/MessagingRateLimited"
        "502":
          description: "`send_failed` (check `retryable`), or the CRM service is unavailable."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MessageSendErrors"
        "504":
          $ref: "#/components/responses/MessagingTimeout"
  /v1/clients/{client_id}/messaging_readiness:
    get:
      summary: Messaging readiness
      tags: [Conversations]
      description: |
        Requires `messages:send` or `conversations:read`. Reports whether the
        client can send SMS and email now, using the same reads a send does:
        the SMS sender (client number, then the agency shared number) and its
        A2P 10DLC / UK registration, and the email sending domain (client
        Mailgun domain, then agency domain, then the UpHex platform domain).
        `sms.reason` is the error code a send would return; `sms.detail` is
        the raw reason. Lead opt-outs, GoHighLevel routing, plan entitlement,
        and wallet balance are checked only at send time. A failed read
        returns `502` `messaging_readiness_unavailable` rather than a guess.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - in: path
          name: client_id
          required: true
          schema:
            type: integer
            minimum: 1
            format: int64
      responses:
        "200":
          description: Readiness verdict.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    $ref: "#/components/schemas/MessagingReadiness"
                  meta:
                    $ref: "#/components/schemas/PublicMeta"
        "400":
          $ref: "#/components/responses/MessagingInvalid"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/MessagingForbidden"
        "404":
          $ref: "#/components/responses/MessagingNotFound"
        "429":
          $ref: "#/components/responses/MessagingRateLimited"
        "502":
          $ref: "#/components/responses/MessagingUnavailable"
        "504":
          $ref: "#/components/responses/MessagingTimeout"
  /v1/metrics:
    get:
      summary: List grouped metrics
      tags: [Metrics]
      description: |
        Requires `metrics:read`. KPI computation and metric storage remain
        backend-owned; this edge validates query shape, resolves agency policy,
        and proxies to backend with the matching backend JWT. When both
        `start_date` and `end_date` are sent, `start_date` must be on or before
        `end_date`.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - in: query
          name: group_by
          required: true
          schema:
            type: string
            enum: [client_id, campaign_id]
          description: Backend grouping dimension for aggregated metrics.
        - in: query
          name: start_date
          schema:
            type: string
            format: date
          example: "2026-05-01"
        - in: query
          name: end_date
          schema:
            type: string
            format: date
          example: "2026-05-29"
        - in: query
          name: client_id
          schema:
            type: string
            maxLength: 1000
          description: Single id or comma-separated list of up to 100 positive integers.
          example: "123,456"
        - in: query
          name: campaign_id
          schema:
            type: string
            maxLength: 1000
          description: Single id or comma-separated list of up to 100 positive integers.
        - in: query
          name: ad_network_connection_id
          schema:
            type: string
            maxLength: 1000
          description: Single id or comma-separated list of up to 100 positive integers.
        - in: query
          name: campaign_pid
          schema:
            type: string
            maxLength: 255
        - in: query
          name: ad_pid
          schema:
            type: string
            maxLength: 255
        - in: query
          name: provider
          schema:
            type: string
            enum: [facebook, google]
        - in: query
          name: sort_by
          schema:
            type: string
            maxLength: 255
            default: group_value
        - in: query
          name: sort_dir
          schema:
            type: string
            enum: [asc, desc]
            default: asc
        - in: query
          name: page
          schema:
            type: integer
            minimum: 1
            default: 1
        - in: query
          name: include_breakdowns
          schema:
            type: boolean
      responses:
        "200":
          description: Normalized data envelope delegated from the backend service. KPI computation remains backend-owned.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicEnvelope"
        "400":
          description: Query parameters failed API-edge validation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: The API credential is missing `metrics:read`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: Backend service unavailable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "504":
          description: Backend service timed out.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
  /v1/metrics/summary:
    get:
      summary: Metrics summary
      tags: [Metrics]
      description: |
        Requires `metrics:read`. KPI computation and metric storage remain
        backend-owned; this edge validates query shape, resolves agency policy,
        and proxies to backend with the matching backend JWT. When both
        `start_date` and `end_date` are sent, `start_date` must be on or before
        `end_date`.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - in: query
          name: start_date
          schema:
            type: string
            format: date
        - in: query
          name: end_date
          schema:
            type: string
            format: date
        - in: query
          name: campaign_id
          schema:
            type: string
            maxLength: 1000
          description: Single id or comma-separated list of up to 100 positive integers.
        - in: query
          name: client_id
          schema:
            type: string
            maxLength: 1000
          description: Single id or comma-separated list of up to 100 positive integers.
        - in: query
          name: ad_network_connection_id
          schema:
            type: string
            maxLength: 1000
          description: Single id or comma-separated list of up to 100 positive integers.
        - in: query
          name: campaign_pid
          schema:
            type: string
            maxLength: 255
        - in: query
          name: ad_pid
          schema:
            type: string
            maxLength: 255
        - in: query
          name: provider
          schema:
            type: string
            enum: [facebook, google]
        - in: query
          name: include_breakdowns
          schema:
            type: boolean
      responses:
        "200":
          description: Normalized data envelope delegated from the backend service. KPI computation remains backend-owned.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicEnvelope"
        "400":
          description: Query parameters failed API-edge validation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: The API credential is missing `metrics:read`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: Backend service unavailable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "504":
          description: Backend service timed out.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
  /v1/calendars:
    get:
      summary: List calendars
      tags: [Calendars]
      description: |
        Requires `calendars:read`. Lists the client's native UpHex calendars
        with their event types. `bookable` is true when an event type can be
        booked through `POST /v1/appointments` (active, on an active calendar,
        and free; paid event types need the hosted checkout). Calendar data
        remains crm-service-owned; the edge sets the agency from the
        credential and checks that `client_id` belongs to it.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - $ref: "#/components/parameters/CalendarClientId"
      responses:
        "200":
          description: Calendars with event types.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Calendar"
                  meta:
                    $ref: "#/components/schemas/CalendarResourceMeta"
        "400":
          $ref: "#/components/responses/CalendarBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/CalendarForbidden"
        "404":
          $ref: "#/components/responses/CalendarClientNotFound"
        "429":
          $ref: "#/components/responses/CalendarRateLimited"
        "502":
          $ref: "#/components/responses/CrmUnavailable"
        "504":
          $ref: "#/components/responses/CrmTimeout"
  /v1/calendars/event_types/{event_type_id}/free_slots:
    get:
      summary: List free slots for an event type
      tags: [Calendars]
      description: |
        Requires `calendars:read`. Returns open start times for a native event
        type between `from` and `to` (at most 62 days apart). Date-only bounds
        are read in `timezone`, which defaults to the calendar's. Slot times
        are UTC; `timezone` echoes the zone the window was read in. Book a
        slot by sending its `starts_at` to `POST /v1/appointments`.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - in: path
          name: event_type_id
          required: true
          schema:
            type: integer
            format: int64
            minimum: 1
        - $ref: "#/components/parameters/CalendarClientId"
        - in: query
          name: from
          required: true
          schema:
            type: string
            maxLength: 64
          description: ISO 8601 date (`2026-11-02`) or datetime with offset.
          example: "2026-11-02"
        - in: query
          name: to
          required: true
          schema:
            type: string
            maxLength: 64
          description: ISO 8601 date or datetime with offset; at most 62 days after `from`.
          example: "2026-11-09"
        - in: query
          name: timezone
          schema:
            type: string
            maxLength: 64
          description: IANA timezone such as `America/Denver`.
      responses:
        "200":
          description: Open slots.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    $ref: "#/components/schemas/FreeSlots"
                  meta:
                    $ref: "#/components/schemas/CalendarResourceMeta"
        "400":
          $ref: "#/components/responses/CalendarBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/CalendarForbidden"
        "404":
          description: |
            `event_type_not_found` when the event type does not exist for the
            client, or the shared-envelope `client_not_found`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "422":
          description: "`calendar_not_bookable` or `range_too_large`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          $ref: "#/components/responses/CalendarRateLimited"
        "502":
          $ref: "#/components/responses/CrmUnavailable"
        "504":
          $ref: "#/components/responses/CrmTimeout"
  /v1/appointments:
    get:
      summary: List appointments
      tags: [Appointments]
      description: |
        Requires `calendars:read`. Returns the client's native bookings and
        the appointments synced from GHL, Calendly, and Google in one list,
        ordered by `starts_at`. Filter by `lead_id` and by a `from`/`to`
        window on `starts_at`; date-only bounds cover the whole UTC day. The
        list is not paginated, so send a window for busy clients.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - $ref: "#/components/parameters/CalendarClientId"
        - in: query
          name: lead_id
          schema:
            type: integer
            format: int64
            minimum: 1
        - in: query
          name: from
          schema:
            type: string
            maxLength: 64
          description: ISO 8601 date or datetime with offset; must be on or before `to`.
        - in: query
          name: to
          schema:
            type: string
            maxLength: 64
          description: ISO 8601 date or datetime with offset.
      responses:
        "200":
          description: Appointments.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Appointment"
                  meta:
                    $ref: "#/components/schemas/CalendarResourceMeta"
        "400":
          $ref: "#/components/responses/CalendarBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/CalendarForbidden"
        "404":
          $ref: "#/components/responses/CalendarClientNotFound"
        "429":
          $ref: "#/components/responses/CalendarRateLimited"
        "502":
          $ref: "#/components/responses/CrmUnavailable"
        "504":
          $ref: "#/components/responses/CrmTimeout"
    post:
      summary: Book an appointment
      tags: [Appointments]
      description: |
        Requires `appointments:write`. Books an existing lead into a native
        event type at `starts_at` (one of the free slots). The calendar's
        availability rules decide the slot; `timezone` is the lead's zone and
        is echoed on the appointment. `Idempotency-Key` is required and works
        with API keys and OAuth access tokens: repeating the same request with
        the same key returns the original appointment with `200` and
        `X-Idempotent-Replay: true`; reusing the key for a different request
        returns `409` `idempotency_key_conflict`. Keys are scoped to your app.
        Booking an event type that is not a native UpHex event type (for
        example a GHL or Calendly calendar id), or that is unknown, inactive,
        or paid, returns `422` `calendar_not_bookable` (v1 limit).
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - in: header
          name: Idempotency-Key
          required: true
          schema:
            type: string
            maxLength: 200
            pattern: "^[A-Za-z0-9._:-]+$"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [event_type_id, lead_id, starts_at, timezone]
              properties:
                event_type_id:
                  type: integer
                  format: int64
                  minimum: 1
                lead_id:
                  type: integer
                  format: int64
                  minimum: 1
                starts_at:
                  type: string
                  format: date-time
                  description: ISO 8601 datetime with an offset.
                timezone:
                  type: string
                  maxLength: 64
                  description: The lead's IANA timezone.
                notes:
                  type: string
                  maxLength: 2000
                client_id:
                  type: integer
                  format: int64
                  minimum: 1
                  description: Optional; when sent, the event type and lead must belong to this client.
            example:
              event_type_id: 20
              lead_id: 42
              starts_at: "2026-11-05T16:00:00Z"
              timezone: America/Denver
              notes: Booked by CloseBot
      responses:
        "201":
          $ref: "#/components/responses/AppointmentCreated"
        "200":
          description: Idempotent replay of the original booking.
          headers:
            X-Idempotent-Replay:
              $ref: "#/components/headers/IdempotentReplay"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AppointmentEnvelope"
        "400":
          description: Edge validation failure, a missing (`Idempotency-Key header is required`) or malformed `Idempotency-Key`, or crm `invalid_request`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/CalendarForbidden"
        "404":
          description: "`lead_not_found`, or the shared-envelope `client_not_found`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "409":
          description: "`slot_unavailable` (the time is already taken) or `idempotency_key_conflict`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "422":
          description: "`calendar_not_bookable`, `outside_availability`, or `lead_missing_contact`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          $ref: "#/components/responses/CalendarRateLimited"
        "502":
          $ref: "#/components/responses/CrmUnavailable"
        "504":
          $ref: "#/components/responses/CrmTimeout"
  /v1/appointments/{id}:
    patch:
      summary: Reschedule an appointment
      tags: [Appointments]
      description: |
        Requires `appointments:write`. Moves a native (`bk_`) appointment to
        a new `starts_at` on the same event type. The result is a new
        appointment whose `previous_appointment_id` is the moved one; retrying
        the same move returns that successor. Synced (`la_`) appointments are
        read-only (`422` `appointment_read_only`).
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - $ref: "#/components/parameters/AppointmentId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [starts_at]
              properties:
                starts_at:
                  type: string
                  format: date-time
                timezone:
                  type: string
                  maxLength: 64
                client_id:
                  type: integer
                  format: int64
                  minimum: 1
      responses:
        "200":
          $ref: "#/components/responses/AppointmentOk"
        "400":
          $ref: "#/components/responses/CalendarBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/CalendarForbidden"
        "404":
          $ref: "#/components/responses/AppointmentNotFound"
        "409":
          description: "`slot_unavailable`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "422":
          description: "`appointment_read_only`, `appointment_not_reschedulable`, `outside_availability`, or `calendar_not_bookable`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          $ref: "#/components/responses/CalendarRateLimited"
        "502":
          $ref: "#/components/responses/CrmUnavailable"
        "504":
          $ref: "#/components/responses/CrmTimeout"
  /v1/appointments/{id}/cancel:
    post:
      summary: Cancel an appointment
      tags: [Appointments]
      description: |
        Requires `appointments:write`. Cancels a native (`bk_`) appointment.
        Canceling an already-canceled appointment returns it unchanged.
      parameters:
        - $ref: "#/components/parameters/AgencyId"
        - $ref: "#/components/parameters/AppointmentId"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                reason:
                  type: string
                  maxLength: 2000
                client_id:
                  type: integer
                  format: int64
                  minimum: 1
      responses:
        "200":
          $ref: "#/components/responses/AppointmentOk"
        "400":
          $ref: "#/components/responses/CalendarBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/CalendarForbidden"
        "404":
          $ref: "#/components/responses/AppointmentNotFound"
        "422":
          description: "`appointment_read_only` or `appointment_not_cancelable`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          $ref: "#/components/responses/CalendarRateLimited"
        "502":
          $ref: "#/components/responses/CrmUnavailable"
        "504":
          $ref: "#/components/responses/CrmTimeout"
  /v1/oauth/clients:
    post:
      summary: Register an OAuth/MCP client
      tags: [OAuth]
      description: |
        Creates an OAuth client under the authenticated developer app. Requires
        an API credential with the `oauth:clients:write` scope. The generated
        client secret is returned once, the response is marked `Cache-Control:
        no-store`, and the secret is then stored only as a digest.
        The response includes the default token endpoint auth method and the
        supported token/revocation client authentication methods, matching the
        authorization-server discovery metadata.
        Requested OAuth client scopes must already be present on the
        authenticating API credential or OAuth access token. If `scope` is
        omitted, the app's default scopes are used and must also be present on
        the authenticating subject.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [redirect_uris]
              properties:
                client_name:
                  type: string
                  maxLength: 120
                  description: Display name. `name` is accepted as an alias. Defaults to `MCP client`.
                redirect_uris:
                  type: array
                  minItems: 1
                  maxItems: 10
                  items:
                    type: string
                    maxLength: 2048
                  description: |
                    Exact-match redirect URIs. Each must be `https`, or `http`
                    only for `localhost`, `127.x.x.x`, or `::1`, with no
                    userinfo or fragment. A comma- or space-separated string is
                    also accepted.
                  example: ["http://localhost:8787/oauth/callback"]
                scope:
                  type: string
                  description: Space- or comma-separated scopes. `scopes` (array or string) is accepted as an alias.
                  example: "clients:read metrics:read"
      responses:
        "201":
          description: OAuth/MCP client was registered. Copy the returned client secret immediately.
          headers:
            Cache-Control:
              $ref: "#/components/headers/NoStoreCacheControl"
            Pragma:
              $ref: "#/components/headers/NoCachePragma"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthClientRegistrationResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: The caller is missing `oauth:clients:write` or requested a scope it does not have.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "422":
          description: The client registration request is invalid.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "413":
          description: Request body exceeds the API edge 1 MB limit.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
  /mcp:
    post:
      summary: Canonical MCP Streamable HTTP gateway
      tags: [MCP]
      description: |
        Canonical customer setup endpoint for UpHex MCP integrations.
        Requires `mcp:read`, `mcp:write`, or legacy `mcp:tools`, plus
        `X-Agency-Id` (or bound OAuth access token agency). Direct CMS
        `https://app.uphex.com/api/mcp` remains available for compatibility.

        The API edge validates the single JSON-RPC request envelope, enforces
        credential scopes and tenant agency authorization, applies rate limits,
        writes request logs, and proxies directly to CMS. Per-tool availability
        and tool execution are governed downstream by CMS metadata. Public tool
        names are not filtered at the edge.

        Envelope rules:
        - Exactly one JSON-RPC object per request (JSON-RPC batches/arrays are rejected).
        - `id` must be a string or JSON-safe integer when provided; null IDs and fractional numbers are rejected.
        - `params` must be an object (not an array) when provided.
        - `method` must be a non-empty string up to 128 characters without control characters.
        - Supported `Mcp-Protocol-Version` values: `2024-11-05`, `2025-03-26`, `2025-06-18`. Unsupported versions are rejected with 400 Bad Request listing supported revisions. If omitted, the header is forwarded as omitted.
        - The gateway is stateless; no `Mcp-Session-Id` headers are issued or forwarded.

        Error boundary:
        - HTTP authentication (401), authorization / tenancy (403), envelope validation / malformed payload (400), rate limit (429), payload size (413), and unsupported method (405) return HTTP error envelopes.
        - CMS timeouts return 504 Gateway Timeout and CMS 5xx returns 502 Bad Gateway with normalized error envelopes and `request_id`.
        - Valid MCP requests to CMS preserve JSON-RPC formatted responses with sensitive tokens, secrets, and digests redacted.

        With `mcp:read`, callers can access read-only tools. With `mcp:write`,
        callers have explicit write access forwarded to CMS as read plus write tool
        intent. Draft template write tools are draft-only: they can create, update,
        upload images to, and soft-delete API-created drafts, but cannot mutate launched/non-draft
        templates. No external public API MCP
        write tool can launch, publish, or activate campaigns. Legacy `mcp:tools` is preserved as full MCP tool access
        for backwards compatibility. CMS 5xx responses are normalized into stable
        gateway errors instead of exposing raw CMS bodies. Raw CMS server-error bodies are not exposed.
        Successful and client-error CMS JSON-RPC responses keep their JSON-RPC shape, but
        obvious token, secret, password, and digest fields are sensitive fields redacted by the API edge
        before developers receive them. `Mcp-Protocol-Version` must also be sent at most once.

        Safe server-side tools/list example:

        ```bash
        curl -X POST https://api.uphex.com/mcp \
          -H "Authorization: Bearer uphex_access_..." \
          -H "Content-Type: application/json" \
          -H "X-Agency-Id: 96" \
          -H "Mcp-Protocol-Version: 2025-03-26" \
          -d '{"jsonrpc":"2.0","id":"1","method":"tools/list","params":{}}'
        ```

        Safe server-side template_details example:

        ```bash
        curl -X POST https://api.uphex.com/mcp \
          -H "Authorization: Bearer uphex_access_..." \
          -H "Content-Type: application/json" \
          -H "X-Agency-Id: 96" \
          -H "Mcp-Protocol-Version: 2025-03-26" \
          -d '{"jsonrpc":"2.0","id":"template-details-1","method":"tools/call","params":{"name":"template_details","arguments":{"template_id":12345}}}'
        ```

        Safe server-side beta template_create example:

        ```bash
        curl -X POST https://api.uphex.com/mcp \
          -H "Authorization: Bearer uphex_access_..." \
          -H "Content-Type: application/json" \
          -H "X-Agency-Id: 96" \
          -H "Mcp-Protocol-Version: 2025-03-26" \
          -d '{"jsonrpc":"2.0","id":"template-create-1","method":"tools/call","params":{"name":"template_create","arguments":{"name":"Disc Rehab Draft","platform":"facebook","headline":"Move without pain","body":"Book a simple consult for disc pain."}}}'
        ```

        Safe server-side beta template_add_image example:

        ```bash
        curl -X POST https://api.uphex.com/mcp \
          -H "Authorization: Bearer uphex_access_..." \
          -H "Content-Type: application/json" \
          -H "X-Agency-Id: 96" \
          -H "Mcp-Protocol-Version: 2025-03-26" \
          -d '{"jsonrpc":"2.0","id":"template-image-1","method":"tools/call","params":{"name":"template_add_image","arguments":{"source":"url","template_id":12345,"image_url":"https://cdn.example.com/template-image.png","image_orientation":"square"}}}'
        ```
      parameters:
        - in: header
          name: X-Agency-Id
          schema:
            type: integer
            minimum: 1
          description: Agency id the MCP request is allowed to operate against. Required unless defaulted by an OAuth access token or single-agency developer app.
        - in: header
          name: Mcp-Protocol-Version
          schema:
            type: string
            enum: ["2024-11-05", "2025-03-26", "2025-06-18"]
            example: "2025-03-26"
          description: Optional MCP protocol version. Supported revisions are 2024-11-05, 2025-03-26, and 2025-06-18. When provided, must be sent at most once and match a supported revision.
      requestBody:
        required: true
        content:
          application/json:
            examples:
              toolsList:
                summary: List available MCP tools
                value:
                  jsonrpc: "2.0"
                  id: "1"
                  method: tools/list
                  params: {}
              templateDetails:
                summary: Call read-only template_details
                value:
                  jsonrpc: "2.0"
                  id: template-details-1
                  method: tools/call
                  params:
                    name: template_details
                    arguments:
                      template_id: 12345
              templateCreate:
                summary: Call beta write template_create
                value:
                  jsonrpc: "2.0"
                  id: template-create-1
                  method: tools/call
                  params:
                    name: template_create
                    arguments:
                      name: Disc Rehab Draft
                      platform: facebook
                      headline: Move without pain
                      body: Book a simple consult for disc pain.
              templateAddImage:
                summary: Call beta write template_add_image
                value:
                  jsonrpc: "2.0"
                  id: template-image-1
                  method: tools/call
                  params:
                    name: template_add_image
                    arguments:
                      source: url
                      template_id: 12345
                      image_url: https://cdn.example.com/template-image.png
                      image_orientation: square
              templateDelete:
                summary: Call beta write template_delete rollback
                value:
                  jsonrpc: "2.0"
                  id: template-delete-1
                  method: tools/call
                  params:
                    name: template_delete
                    arguments:
                      template_id: 12345
              initialize:
                summary: Initialize MCP client
                value:
                  jsonrpc: "2.0"
                  id: "init-1"
                  method: initialize
                  params:
                    protocolVersion: "2025-03-26"
                    clientInfo:
                      name: partner-integration
                      version: "1.0.0"
            schema:
              type: object
              description: Single MCP JSON-RPC request or notification object. Batch requests and JSON-RPC arrays are not supported.
      responses:
        "200":
          description: MCP JSON-RPC response returned by CMS with sensitive fields redacted by the API edge.
          headers:
            X-Request-ID:
              $ref: "#/components/headers/RequestId"
        "202":
          description: MCP notification accepted.
        "400":
          description: Missing or invalid agency header, unsupported protocol version, null ID, array params, JSON-RPC batch, or malformed JSON-RPC envelope.
        "401":
          description: Missing or invalid MCP credentials.
          headers:
            WWW-Authenticate:
              $ref: "#/components/headers/McpProtectedResourceChallenge"
        "403":
          description: Missing an MCP scope or agency not allowed.
        "413":
          description: Request body exceeds the API edge 1 MB limit.
        "429":
          description: Developer app rate limit exceeded for API key or OAuth traffic.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicErrors"
        "502":
          description: CMS unavailable or returned a server error. Raw CMS server-error bodies are not exposed.
        "504":
          description: CMS timed out.
    get:
      summary: MCP stream unsupported
      tags: [MCP]
      description: The API edge is stateless and does not hold server-to-client streams.
      responses:
        "405":
          description: Method not allowed.
    delete:
      summary: MCP session termination unsupported
      tags: [MCP]
      description: The API edge is stateless and has no per-session state to reclaim.
      responses:
        "405":
          description: Method not allowed.
  /oauth/authorize:
    get:
      summary: Start OAuth authorization
      tags: [OAuth]
      security: []
      description: |
        Browser authorization screen for signed-in developer users. Supports the
        authorization code flow with PKCE S256. The signed-in developer user
        must belong to the OAuth client's owning developer account. `state` is
        capped at 512 bytes before redirect reflection, `client_id` is bounded
        before lookup, `scope` is bounded before parsing, and `code_challenge`
        must be a 43-128 character PKCE S256 challenge.

        Who can authorize: a Developer Portal user signed in to the same
        developer account that owns the OAuth client. Without a portal
        session the browser is redirected to `/sign-in`. Exception: for
        public apps (approved by UpHex review; see "Publishing your app"),
        any agency user with the owner, admin, or developer role may install
        the app on one of their agencies. Requested scopes and the
        `redirect_uri` must also be in the app's approved review snapshot
        (`invalid_scope` / `invalid_redirect_uri` otherwise). Delisted apps
        refuse new installs (`not_installable`). The consent form posts an `agency_id` (a picker when the
        user has several agencies), which records one installation per app
        and agency. Revoking the installation from Connected apps revokes
        its tokens immediately.

        Flow: the client sends the browser here with `response_type=code`, a
        registered `redirect_uri` (exact match), `code_challenge` =
        base64url(SHA-256(`code_verifier`)) without padding,
        `code_challenge_method=S256`, optional `state`, and optional `scope`
        (space-delimited; must be a subset of the client's scopes, defaults
        to all client scopes). After the user approves, the browser is
        redirected to `redirect_uri?code=uphex_auth_code_...&state=...`. The
        code is single-use and expires after 10 minutes. The resulting tokens
        are bound to one agency, resolved from the approving user's account
        and the app's allowed agencies (or, for public apps, the agency
        chosen on the consent form); if none can be resolved the code is
        not issued.

        Errors with an unknown client, a client from another developer
        account, an unregistered `redirect_uri`, or an invalid `state` render
        an error page and are never redirected; so do a malformed `scope`
        (over 1,024 bytes or more than 32 scopes) and a failure to resolve
        an agency at approval. Other errors redirect to
        `redirect_uri?error=<code>&state=...` with `unsupported_response_type`,
        `invalid_request` (bad PKCE challenge or method), or `invalid_scope`
        (a scope outside the client's scopes).
      parameters:
        - in: query
          name: client_id
          required: true
          schema:
            type: string
        - in: query
          name: redirect_uri
          required: true
          schema:
            type: string
        - in: query
          name: response_type
          required: true
          schema:
            type: string
            enum: [code]
        - in: query
          name: scope
          schema:
            type: string
        - in: query
          name: state
          schema:
            type: string
            maxLength: 512
        - in: query
          name: code_challenge
          required: true
          schema:
            type: string
            minLength: 43
            maxLength: 128
            pattern: "^[A-Za-z0-9._~-]+$"
        - in: query
          name: code_challenge_method
          required: true
          schema:
            type: string
            enum: [S256]
      responses:
        "200":
          description: Developer consent screen.
        "302":
          description: Redirects to sign-in when no developer session exists.
    post:
      summary: Approve OAuth authorization
      tags: [OAuth]
      security: []
      responses:
        "302":
          description: Redirects back to the registered redirect URI with code or error.
  /oauth/token:
    post:
      summary: Exchange OAuth authorization code
      tags: [OAuth]
      security: []
      description: |
        Exchanges a one-time authorization code, or refreshes an existing token
        with refresh-token rotation. Requires OAuth client authentication with
        the generated client secret, via HTTP Basic (`client_secret_basic`) or
        `client_id`/`client_secret` body fields (`client_secret_post`).
        Supported grants are `authorization_code` and `refresh_token` only;
        there is no `client_credentials` grant.

        `grant_type=authorization_code` requires `code`, the same
        `redirect_uri` used at `/oauth/authorize`, and the PKCE
        `code_verifier`. `grant_type=refresh_token` requires `refresh_token`.
        Access tokens last one hour and refresh tokens 30 days. Refreshing
        returns a new access/refresh pair and immediately revokes the previous
        access and refresh token; reusing an old refresh token returns
        `invalid_grant`.
        Attempts are rate limited by source IP and well-formed generated OAuth
        client id.
        When HTTP Basic client authentication is used, `Authorization` must be
        sent at most once.
        Token exchange attempts are written to developer request logs with
        flow, grant type, auth mode, outcome, and safe client identifiers only;
        client secrets, authorization codes, access tokens, and refresh tokens
        are not stored.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/OAuthTokenRequest"
          application/json:
            schema:
              $ref: "#/components/schemas/OAuthTokenRequest"
      responses:
        "200":
          description: Access token response.
          headers:
            Cache-Control:
              $ref: "#/components/headers/NoStoreCacheControl"
            Pragma:
              $ref: "#/components/headers/NoCachePragma"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthTokenResponse"
        "400":
          description: Invalid grant, verifier, or unsupported grant type.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthErrorResponse"
        "401":
          description: Invalid OAuth client credentials.
          headers:
            WWW-Authenticate:
              $ref: "#/components/headers/OAuthClientAuthChallenge"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthErrorResponse"
        "413":
          description: Request body exceeds the API edge 1 MB limit.
        "429":
          description: OAuth token endpoint rate limit exceeded.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthErrorResponse"
  /oauth/revoke:
    post:
      summary: Revoke OAuth token
      tags: [OAuth]
      security: []
      description: |
        Revokes an issued OAuth access or refresh token. The requesting OAuth
        client must authenticate. Unknown tokens and tokens owned by another
        client are treated as successful no-ops. Attempts are rate limited by
        source IP and well-formed generated OAuth client id. When HTTP Basic
        client authentication is used, `Authorization` must be sent at most once.
        Revocation attempts are written to developer request logs with flow,
        auth mode, outcome, and safe client identifiers only; client secrets,
        access tokens, and refresh tokens are not stored.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/OAuthRevocationRequest"
          application/json:
            schema:
              $ref: "#/components/schemas/OAuthRevocationRequest"
      responses:
        "200":
          description: Token revoked. The response body is intentionally empty.
          headers:
            Cache-Control:
              $ref: "#/components/headers/NoStoreCacheControl"
            Pragma:
              $ref: "#/components/headers/NoCachePragma"
        "400":
          description: Invalid request shape.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthErrorResponse"
        "401":
          description: Invalid client credentials.
          headers:
            WWW-Authenticate:
              $ref: "#/components/headers/OAuthClientAuthChallenge"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthErrorResponse"
        "413":
          description: Request body exceeds the API edge 1 MB limit.
        "429":
          description: OAuth revocation endpoint rate limit exceeded.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"
            X-RateLimit-Limit:
              $ref: "#/components/headers/RateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/RateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/RateLimitReset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthErrorResponse"
x-webhooks:
  lead.created:
    post:
      summary: lead.created webhook delivery
      tags: [Webhooks]
      security: []
      description: |
        Sent by UpHex to each active webhook endpoint that subscribes to
        `lead.created`, for apps authorized for the agency with `leads:read`,
        when UpHex's CRM creates a lead (including through `POST /v1/leads`).
        The API edge does not emit this event itself. Endpoints
        are created, have their secret rotated, are deleted (disabled; history
        is kept), and have failed deliveries retried from the Developer Portal;
        the `whsec_...` signing secret is shown once.

        Endpoint URLs must be public HTTPS URLs without userinfo or fragments.
        `localhost` hostnames and private, link-local, or reserved IP literals
        are rejected when the endpoint is saved. Before every delivery
        attempt the hostname is also resolved, and the attempt is cancelled
        if it resolves to no address or to a private or local address.

        Signature (matches `UphexApi.Webhooks.Signature`):
        1. `key` = lowercase hex SHA-256 digest of the raw signing secret
           (`whsec_...`). The 64-character hex string itself is the HMAC key.
        2. `signed_payload` = `UpHex-Signature-Timestamp` + `.` + the raw
           request body bytes exactly as received.
        3. `expected` = lowercase hex HMAC-SHA256(`key`, `signed_payload`).
        4. Compare `expected` with the value after `v1=` in
           `UpHex-Signature` using a constant-time comparison.
        Verify against the raw body, not re-serialized JSON. UpHex does not
        enforce a timestamp tolerance; receivers should reject stale
        timestamps (for example older than 5 minutes) to limit replay.

        Delivery and retries: a `2xx` response marks the delivery delivered.
        Any other status, a connection error, or no response within 10
        seconds counts as a failed attempt. Automatic delivery makes at most 5
        attempts, retried with the job queue's exponential backoff
        (17s, 19s, 23s, and 31s after successive failures; the portal's
        "next retry" time uses the same schedule). Each attempt is re-signed with a new timestamp and keeps
        the same `UpHex-Delivery-Id`. Deliveries are cancelled without retry
        when the endpoint is disabled, the app or developer account is not
        active, or the URL fails the safety check. Failed deliveries can be
        retried manually from the portal; each manual retry starts a new run
        of up to 5 attempts. Delivery is at-least-once and
        unordered; de-duplicate on `UpHex-Delivery-Id`.

        This section is shared by every event below; only the event name and
        body differ.

        `lead.created` is produced only by UpHex's CRM, once per created lead
        (including leads created through `POST /v1/leads`; a `match` that
        returns an existing lead sends nothing). It is delivered to every
        endpoint that lists `lead.created`, of apps authorized for the agency
        with `leads:read`. The body is `WebhookCrmEvent` with `data.data` =
        `LeadEventData`.
      parameters:
        - in: header
          name: UpHex-Event
          required: true
          schema:
            type: string
            enum: [lead.created]
        - in: header
          name: UpHex-Delivery-Id
          required: true
          schema:
            type: string
          description: Stable id for this delivery across retries.
        - in: header
          name: UpHex-Signature-Timestamp
          required: true
          schema:
            type: integer
          description: Unix seconds when this attempt was signed.
        - in: header
          name: UpHex-Signature
          required: true
          schema:
            type: string
            pattern: "^v1=[0-9a-f]{64}$"
          description: "`v1=` followed by the lowercase hex HMAC-SHA256 signature."
        - in: header
          name: User-Agent
          schema:
            type: string
            enum: [UpHex-Webhook/1.0]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookCrmEvent"
      responses:
        "200":
          description: Any `2xx` status acknowledges the delivery. Other statuses are retried.
  lead.updated:
    post:
      summary: lead.updated webhook delivery
      tags: [Webhooks]
      security: []
      description: |
        A lead's fields changed.
        Delivered to every active endpoint that lists `lead.updated`, owned by an
        active app authorized for the event's agency that holds `leads:read`:
        a public (or delisted) app through an active installation for that agency whose
        granted scopes include it, any other app through the developer
        account agency or an operator agency installation plus an active API key or OAuth
        client with the scope.
        Headers, signature, retries, and URL safety are identical to
        `lead.created`; `UpHex-Event` carries this event name.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookCrmEvent"
                - type: object
                  properties:
                    event:
                      type: string
                      enum: [lead.updated]
                    data:
                      type: object
                      properties:
                        data:
                          $ref: "#/components/schemas/LeadEventData"
      responses:
        "200":
          description: Any `2xx` status acknowledges the delivery. Other statuses are retried.
  lead.tag_added:
    post:
      summary: lead.tag_added webhook delivery
      tags: [Webhooks]
      security: []
      description: |
        A tag was added to a lead.
        Delivered to every active endpoint that lists `lead.tag_added`, owned by an
        active app authorized for the event's agency that holds `leads:read`:
        a public (or delisted) app through an active installation for that agency whose
        granted scopes include it, any other app through the developer
        account agency or an operator agency installation plus an active API key or OAuth
        client with the scope.
        Headers, signature, retries, and URL safety are identical to
        `lead.created`; `UpHex-Event` carries this event name.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookCrmEvent"
                - type: object
                  properties:
                    event:
                      type: string
                      enum: [lead.tag_added]
                    data:
                      type: object
                      properties:
                        data:
                          $ref: "#/components/schemas/LeadTagEventData"
      responses:
        "200":
          description: Any `2xx` status acknowledges the delivery. Other statuses are retried.
  lead.tag_removed:
    post:
      summary: lead.tag_removed webhook delivery
      tags: [Webhooks]
      security: []
      description: |
        A tag was removed from a lead.
        Delivered to every active endpoint that lists `lead.tag_removed`, owned by an
        active app authorized for the event's agency that holds `leads:read`:
        a public (or delisted) app through an active installation for that agency whose
        granted scopes include it, any other app through the developer
        account agency or an operator agency installation plus an active API key or OAuth
        client with the scope.
        Headers, signature, retries, and URL safety are identical to
        `lead.created`; `UpHex-Event` carries this event name.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookCrmEvent"
                - type: object
                  properties:
                    event:
                      type: string
                      enum: [lead.tag_removed]
                    data:
                      type: object
                      properties:
                        data:
                          $ref: "#/components/schemas/LeadTagEventData"
      responses:
        "200":
          description: Any `2xx` status acknowledges the delivery. Other statuses are retried.
  message.inbound:
    post:
      summary: message.inbound webhook delivery
      tags: [Webhooks]
      security: []
      description: |
        A lead sent a new inbound SMS, email, or custom-channel message.
        Delivered to every active endpoint that lists `message.inbound`, owned by an
        active app authorized for the event's agency that holds `conversations:read`:
        a public (or delisted) app through an active installation for that agency whose
        granted scopes include it, any other app through the developer
        account agency or an operator agency installation plus an active API key or OAuth
        client with the scope.
        Headers, signature, retries, and URL safety are identical to
        `lead.created`; `UpHex-Event` carries this event name.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookCrmEvent"
                - type: object
                  properties:
                    event:
                      type: string
                      enum: [message.inbound]
                    data:
                      type: object
                      properties:
                        data:
                          $ref: "#/components/schemas/MessageInboundEventData"
      responses:
        "200":
          description: Any `2xx` status acknowledges the delivery. Other statuses are retried.
  message.outbound.status:
    post:
      summary: message.outbound.status webhook delivery
      tags: [Webhooks]
      security: []
      description: |
        An outbound message was delivered or failed.
        Delivered to every active endpoint that lists `message.outbound.status`, owned by an
        active app authorized for the event's agency that holds `conversations:read`:
        a public (or delisted) app through an active installation for that agency whose
        granted scopes include it, any other app through the developer
        account agency or an operator agency installation plus an active API key or OAuth
        client with the scope.
        Headers, signature, retries, and URL safety are identical to
        `lead.created`; `UpHex-Event` carries this event name.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookCrmEvent"
                - type: object
                  properties:
                    event:
                      type: string
                      enum: [message.outbound.status]
                    data:
                      type: object
                      properties:
                        data:
                          $ref: "#/components/schemas/MessageOutboundStatusEventData"
      responses:
        "200":
          description: Any `2xx` status acknowledges the delivery. Other statuses are retried.
  appointment.created:
    post:
      summary: appointment.created webhook delivery
      tags: [Webhooks]
      security: []
      description: |
        An appointment was booked on one of the agency's client calendars.
        Delivered to every active endpoint that lists `appointment.created`, owned by an
        active app authorized for the event's agency that holds `calendars:read`:
        a public (or delisted) app through an active installation for that agency whose
        granted scopes include it, any other app through the developer
        account agency or an operator agency installation plus an active API key or OAuth
        client with the scope.
        Headers, signature, retries, and URL safety are identical to
        `lead.created`; `UpHex-Event` carries this event name.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookCrmEvent"
                - type: object
                  properties:
                    event:
                      type: string
                      enum: [appointment.created]
                    data:
                      type: object
                      properties:
                        data:
                          $ref: "#/components/schemas/AppointmentEventData"
      responses:
        "200":
          description: Any `2xx` status acknowledges the delivery. Other statuses are retried.
  appointment.rescheduled:
    post:
      summary: appointment.rescheduled webhook delivery
      tags: [Webhooks]
      security: []
      description: |
        An existing appointment moved to a new time.
        Delivered to every active endpoint that lists `appointment.rescheduled`, owned by an
        active app authorized for the event's agency that holds `calendars:read`:
        a public (or delisted) app through an active installation for that agency whose
        granted scopes include it, any other app through the developer
        account agency or an operator agency installation plus an active API key or OAuth
        client with the scope.
        Headers, signature, retries, and URL safety are identical to
        `lead.created`; `UpHex-Event` carries this event name.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookCrmEvent"
                - type: object
                  properties:
                    event:
                      type: string
                      enum: [appointment.rescheduled]
                    data:
                      type: object
                      properties:
                        data:
                          $ref: "#/components/schemas/AppointmentEventData"
      responses:
        "200":
          description: Any `2xx` status acknowledges the delivery. Other statuses are retried.
  appointment.canceled:
    post:
      summary: appointment.canceled webhook delivery
      tags: [Webhooks]
      security: []
      description: |
        An existing appointment was canceled.
        Delivered to every active endpoint that lists `appointment.canceled`, owned by an
        active app authorized for the event's agency that holds `calendars:read`:
        a public (or delisted) app through an active installation for that agency whose
        granted scopes include it, any other app through the developer
        account agency or an operator agency installation plus an active API key or OAuth
        client with the scope.
        Headers, signature, retries, and URL safety are identical to
        `lead.created`; `UpHex-Event` carries this event name.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookCrmEvent"
                - type: object
                  properties:
                    event:
                      type: string
                      enum: [appointment.canceled]
                    data:
                      type: object
                      properties:
                        data:
                          $ref: "#/components/schemas/AppointmentEventData"
      responses:
        "200":
          description: Any `2xx` status acknowledges the delivery. Other statuses are retried.
  app.uninstalled:
    post:
      summary: app.uninstalled webhook delivery
      tags: [Webhooks]
      security: []
      description: |
        An agency revoked this partner app's installation. Delivered only to the uninstalled app; `client_id` is null and `data` has `app_id` and `installation_id`.
        Not scope-gated and never fanned out by agency.
        Headers, signature, retries, and URL safety are identical to
        `lead.created`; `UpHex-Event` carries this event name.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookCrmEvent"
                - type: object
                  properties:
                    event:
                      type: string
                      enum: [app.uninstalled]
                    data:
                      type: object
                      properties:
                        data:
                          $ref: "#/components/schemas/AppUninstalledEventData"
      responses:
        "200":
          description: Any `2xx` status acknowledges the delivery. Other statuses are retried.
components:
  parameters:
    CrmClientId:
      in: query
      name: client_id
      required: true
      schema:
        type: integer
        minimum: 1
      description: Client that owns the lead. A client outside the authorized agency returns `404` `client_not_found`.
    LeadId:
      in: path
      name: id
      required: true
      schema:
        type: integer
        minimum: 1
        format: int64
      description: CRM lead id.
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      schema:
        type: string
        maxLength: 255
        pattern: "^[A-Za-z0-9._:-]+$"
      description: |
        Optional retry-safety key for API key, legacy bearer-token, and OAuth
        access token requests. API-key keys are scoped to the API key; OAuth
        keys are scoped to the OAuth client and the token's agency, so
        refreshed or rotated tokens of the same client share them for 24 hours.
        Reusing the key with the same payload replays the original response
        with `X-Idempotent-Replay: true`; a different payload returns `409`.
        Agency errors are returned before the key is claimed,
        and the resolved agency is part of the request fingerprint.
        Oversized public responses are not stored for replay; the key is
        released so retry traffic can re-run instead of
        remaining stuck as processing.
    AgencyId:
      in: header
      name: X-Agency-Id
      required: false
      schema:
        type: integer
        minimum: 1
      description: |
        Optional agency override for multi-agency developer apps. When omitted,
        the edge uses the developer account agency or the app's single allowed
        agency. `X-Agency-Id` must be a bounded positive integer.
        `X-Agency-Id` must be sent at most once. Query-string `agency_id` is intentionally rejected.
        Errors: `400` `missing_agency_id` when no agency can be resolved,
        `400` `invalid_agency_id`, `400` `ambiguous_agency_id`, and `403`
        `agency_not_allowed` when the app may not access the agency (or the
        header does not match an OAuth token's bound agency).
    CalendarClientId:
      in: query
      name: client_id
      required: true
      schema:
        type: integer
        format: int64
        minimum: 1
      description: |
        UpHex client id. Must belong to the credential's agency; other ids
        return `404` `client_not_found`.
    AppointmentId:
      in: path
      name: id
      required: true
      schema:
        type: string
        pattern: "^(bk|la)_[0-9]+$"
      description: Public appointment id. Only native `bk_` appointments can be changed.
    MessagingCursor:
      in: query
      name: cursor
      schema:
        type: string
      description: Opaque `meta.next_cursor` from the previous page.
  headers:
    RequestId:
      description: Correlation id for UpHex support, audit logs, and downstream tracing.
      schema:
        type: string
    IdempotentReplay:
      description: Present with value `true` when a stored idempotent response is replayed.
      schema:
        type: string
        enum: ["true"]
    OAuthClientAuthChallenge:
      description: OAuth client authentication challenge.
      schema:
        type: string
        example: Basic realm="UpHex OAuth"
    RetryAfter:
      description: Seconds to wait before retrying after an API-edge rate limit.
      schema:
        type: integer
        minimum: 0
    RateLimitLimit:
      description: Maximum requests allowed in the current app rate-limit window.
      schema:
        type: integer
        minimum: 1
    RateLimitRemaining:
      description: Requests remaining in the current app rate-limit window.
      schema:
        type: integer
        minimum: 0
    RateLimitReset:
      description: Unix timestamp when the current app rate-limit window resets.
      schema:
        type: integer
        minimum: 0
    NoStoreCacheControl:
      description: Prevents storage of token or one-time-secret responses.
      schema:
        type: string
        example: no-store
    NoCachePragma:
      description: Legacy cache prevention header for token or one-time-secret responses.
      schema:
        type: string
        example: no-cache
    PublicApiAuthChallenge:
      description: Public API bearer authentication challenge.
      schema:
        type: string
        example: Bearer realm="UpHex API"
    McpProtectedResourceChallenge:
      description: MCP bearer authentication challenge with protected resource metadata discovery.
      schema:
        type: string
        example: Bearer realm="UpHex MCP", resource_metadata="https://api.uphex.com/.well-known/oauth-protected-resource"
  responses:
    MessagingForbidden:
      description: The credential is missing the route's scope, or the app may not access the agency.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/PublicErrors"
    MessagingNotFound:
      description: "`Client not found` for a `client_id` outside the authorized agency, or crm-service did not find the lead for that client (`downstream_rejected`)."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/PublicErrors"
    MessagingInvalid:
      description: Query parameters failed API-edge validation (including a missing `client_id`, an unsupported parameter, or an invalid `cursor`), or `X-Agency-Id` is invalid.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/PublicErrors"
    MessagingRateLimited:
      description: Developer app rate limit exceeded for API key or OAuth traffic.
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
        X-RateLimit-Limit:
          $ref: "#/components/headers/RateLimitLimit"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/RateLimitRemaining"
        X-RateLimit-Reset:
          $ref: "#/components/headers/RateLimitReset"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/PublicErrors"
    MessagingUnavailable:
      description: "The CRM or messaging service is unavailable (`crm_unavailable`, `crm_not_configured`, `downstream_unavailable`, `messaging_readiness_unavailable`)."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/PublicErrors"
    MessagingTimeout:
      description: A downstream service timed out (`downstream_timeout`).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/PublicErrors"
    Unauthorized:
      description: |
        Missing, malformed, mixed, revoked, or expired credentials, or the
        credential's developer app or account is not active. The body is
        always `{"errors":{"base":["Unauthorized"]}}`.
      headers:
        WWW-Authenticate:
          $ref: "#/components/headers/PublicApiAuthChallenge"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/PublicErrors"
    CalendarBadRequest:
      description: |
        Edge validation failure (`{"errors":{"base":[...]}}`), or `invalid_request`
        when crm-service rejected a parameter (for example an unknown timezone).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/PublicErrors"
    CalendarForbidden:
      description: |
        The credential lacks the route's scope (`Missing required scope: ...`) or
        the app may not access the resolved agency.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/PublicErrors"
    CalendarClientNotFound:
      description: |
        `client_id` does not belong to the credential's agency. Uses the shared
        envelope `{"errors":{"base":["Client not found"]}}`.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/PublicErrors"
    CalendarRateLimited:
      description: Developer app rate limit exceeded for API key or OAuth traffic.
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/PublicErrors"
    CrmUnavailable:
      description: "`downstream_unavailable` or `crm_not_configured`: crm-service could not complete the request."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/PublicErrors"
    CrmTimeout:
      description: "`downstream_timeout`: crm-service did not respond in time."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/PublicErrors"
    AppointmentCreated:
      description: Appointment booked.
      headers:
        X-Request-ID:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/AppointmentEnvelope"
    AppointmentOk:
      description: The updated appointment.
      headers:
        X-Request-ID:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/AppointmentEnvelope"
    AppointmentNotFound:
      description: "`appointment_not_found`: unknown id, malformed id, or an appointment outside the credential's agency."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/PublicErrors"
  schemas:
    WebhookCrmEvent:
      type: object
      required: [event, data]
      description: |
        JSON body of every webhook delivery. All events, including
        `lead.created`, are sourced from the owning service (UpHex's CRM for
        lead events).
      properties:
        event:
          type: string
          description: Event name, identical to `UpHex-Event`.
        data:
          $ref: "#/components/schemas/WebhookEventPayload"
      example:
        event: message.inbound
        data:
          event_id: 6f1c1d2e-8a1b-4c3d-9e4f-5a6b7c8d9e0f
          type: message.inbound
          agency_id: 96
          client_id: 123
          occurred_at: "2026-10-22T18:00:00.000000Z"
          data:
            channel: sms
            lead_id: 987
            body: Is Tuesday still open?
    WebhookEventPayload:
      type: object
      required: [event_id, type, agency_id, client_id, occurred_at, data]
      properties:
        event_id:
          type: string
          format: uuid
          description: |
            Identifies the source change. It is stable even if the owning
            service publishes the change again, so de-duplicate business
            effects on it (and transport retries on `UpHex-Delivery-Id`).
        type:
          type: string
          description: Event name.
        agency_id:
          type: integer
          format: int64
        client_id:
          type: integer
          format: int64
          nullable: true
          description: Client the event belongs to. Null only for `app.uninstalled`.
        occurred_at:
          type: string
          format: date-time
        data:
          type: object
          description: Event-specific fields; see the per-event schema.
    LeadEventData:
      type: object
      required: [lead_id]
      properties:
        lead_id:
          type: integer
          format: int64
        lead:
          type: object
          additionalProperties: true
          description: Lead snapshot after the change, in the `/v1/leads` shape.
        changed_fields:
          type: array
          items:
            type: string
          description: Present on `lead.updated`.
    LeadTagEventData:
      type: object
      required: [lead_id, tag]
      properties:
        lead_id:
          type: integer
          format: int64
        tag:
          type: string
    MessageInboundEventData:
      type: object
      required: [channel]
      properties:
        channel:
          type: string
          enum: [sms, email, custom]
        lead_id:
          type: integer
          format: int64
        conversation_id:
          type: string
        message_id:
          type: string
        body:
          type: string
        subject:
          type: string
          description: Email only.
        custom_channel:
          type: string
          description: Custom channel name when `channel` is `custom`.
    MessageOutboundStatusEventData:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [delivered, failed]
        channel:
          type: string
          enum: [sms, email, custom]
        lead_id:
          type: integer
          format: int64
        message_id:
          type: string
        error:
          type: string
          description: Present when `status` is `failed`.
    AppointmentEventData:
      type: object
      properties:
        appointment_id:
          type: string
          description: |
            Same id as `GET /v1/appointments`: `bk_<id>` for native bookings
            (reschedulable and cancelable through the API) and `la_<id>` for
            appointments synced from GHL, Calendly, or Google (read-only).
        calendar_id:
          type: integer
          format: int64
          nullable: true
          description: Native calendar id, as on `Appointment`; null for synced appointments.
        lead_id:
          type: integer
          format: int64
        start_time:
          type: string
          format: date-time
        end_time:
          type: string
          format: date-time
        previous_start_time:
          type: string
          format: date-time
          description: Present on `appointment.rescheduled`.
    AppUninstalledEventData:
      type: object
      required: [app_id, installation_id]
      properties:
        app_id:
          type: integer
          format: int64
        installation_id:
          type: integer
          format: int64
    PublicMeta:
      type: object
      properties:
        resource:
          type: string
        matched:
          type: boolean
          description: "`POST /v1/leads` with `match` only."
        page:
          type: integer
        per_page:
          type: integer
        total_entries:
          type: integer
        total_pages:
          type: integer
        next_cursor:
          type: string
          nullable: true
          description: "`GET /v1/leads` with `updated_since` or `cursor` only. Send as `cursor` for the next page; `null` on the last page."
        has_more:
          type: boolean
          description: "`GET /v1/leads` with `updated_since` or `cursor` only."
    Lead:
      type: object
      description: |
        CRM lead. Ids are crm-service lead ids. `ghl_tags` is a deprecated
        alias of `tags`, kept for one release. `source` is how the lead
        reached UpHex (for example `public_api`, `facebook_lead`, `ez_form`, `smart_tracking`) and
        `updated_at` is its last change, the field `updated_since` filters on.
      properties:
        id:
          type: integer
        client_id:
          type: integer
        full_name:
          type: string
          nullable: true
        email:
          type: string
          nullable: true
        phone:
          type: string
          nullable: true
        status:
          type: string
          example: New
        pipeline_id:
          type: integer
          nullable: true
        pipeline_stage_id:
          type: integer
          nullable: true
        campaign_id:
          type: integer
          nullable: true
        campaign_name:
          type: string
          nullable: true
        created_time:
          type: string
          nullable: true
          description: ISO 8601 timestamp without a UTC offset.
        source:
          type: string
          nullable: true
          example: public_api
        updated_at:
          type: string
          format: date-time
          nullable: true
          description: ISO 8601 UTC timestamp of the lead's last change.
        tags:
          type: array
          items:
            type: string
        ghl_tags:
          type: array
          deprecated: true
          items:
            type: string
        metadata:
          type: object
          additionalProperties: true
      example:
        id: 987
        client_id: 123
        full_name: Ada Lovelace
        email: ada@example.com
        phone: "555-0100"
        status: New
        pipeline_id: 7
        pipeline_stage_id: 42
        campaign_id: null
        campaign_name: null
        created_time: "2026-10-21T15:04:05"
        source: public_api
        updated_at: "2026-10-22T18:30:05Z"
        tags: [closebot]
        ghl_tags: [closebot]
        metadata: {}
    LeadNote:
      type: object
      properties:
        id:
          type: integer
        content:
          type: string
        author_type:
          type: string
          enum: [agency_user, client_user, system]
        author_name:
          type: string
          nullable: true
        created_at:
          type: string
        updated_at:
          type: string
    LeadActivity:
      type: object
      properties:
        id:
          type: integer
        activity_type:
          type: string
        description:
          type: string
        metadata:
          type: object
          additionalProperties: true
        occurred_at:
          type: string
        created_at:
          type: string
    Tag:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        client_id:
          type: integer
          nullable: true
          description: "`null` for agency-wide tags."
    Pipeline:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        client_id:
          type: integer
          nullable: true
        is_default:
          type: boolean
        position:
          type: integer
        stages:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
              name:
                type: string
              position:
                type: integer
              color:
                type: string
                nullable: true
    ContactChannel:
      type: object
      additionalProperties: true
      description: Per-channel preference from the messaging service, including the channel name and whether it is suppressed (do not disturb).
    AuthenticatedHealthResponse:
      type: object
      required: [status, authenticated, developer_app, timestamp]
      properties:
        status:
          type: string
          enum: [ok]
        authenticated:
          type: boolean
          enum: [true]
        developer_app:
          type: object
          required: [id, name, environment, scopes]
          properties:
            id:
              type: integer
            name:
              type: string
            environment:
              type: string
            scopes:
              type: array
              items:
                type: string
        agency_id:
          type: integer
          nullable: true
          description: |
            The agency an OAuth access token is bound to (for example the agency
            whose admin approved the app). Null for API keys and legacy tokens.
        timestamp:
          type: string
          format: date-time
    LegacyTokenResponse:
      type: object
      required: [token, token_type, expires_in]
      properties:
        token:
          type: string
          description: One-hour legacy bearer token for protected public API routes.
        token_type:
          type: string
          enum: [Bearer]
        expires_in:
          type: integer
          minimum: 1
          example: 3600
    OAuthClientRegistrationResponse:
      type: object
      required:
        - client_id
        - client_secret
        - client_secret_prefix
        - client_name
        - redirect_uris
        - scope
        - token_endpoint_auth_method
        - token_endpoint_auth_methods_supported
        - revocation_endpoint_auth_methods_supported
        - grant_types
        - response_types
      properties:
        client_id:
          type: string
          pattern: "^uphex_oauth_[A-Za-z0-9_-]+$"
        client_secret:
          type: string
          description: One-time OAuth client secret. Store this immediately; it cannot be retrieved again.
          pattern: "^uphex_oauth_secret_[A-Za-z0-9_-]+$"
        client_secret_prefix:
          type: string
          description: Safe prefix shown later in portal controls and request logs.
        client_name:
          type: string
        redirect_uris:
          type: array
          items:
            type: string
            maxLength: 2048
        scope:
          type: string
          example: "clients:read metrics:read"
        token_endpoint_auth_method:
          type: string
          enum: [client_secret_basic]
        token_endpoint_auth_methods_supported:
          type: array
          items:
            type: string
            enum: [client_secret_basic, client_secret_post]
          example: [client_secret_basic, client_secret_post]
        revocation_endpoint_auth_methods_supported:
          type: array
          items:
            type: string
            enum: [client_secret_basic, client_secret_post]
          example: [client_secret_basic, client_secret_post]
        grant_types:
          type: array
          items:
            type: string
            enum: [authorization_code, refresh_token]
          example: [authorization_code, refresh_token]
        response_types:
          type: array
          items:
            type: string
            enum: [code]
          example: [code]
    OAuthTokenRequest:
      type: object
      required: [grant_type]
      properties:
        grant_type:
          type: string
          enum: [authorization_code, refresh_token]
        code:
          type: string
          maxLength: 128
          pattern: "^uphex_auth_code_[A-Za-z0-9_-]+$"
        redirect_uri:
          type: string
          maxLength: 2048
        client_id:
          type: string
          maxLength: 128
          pattern: "^uphex_oauth_[A-Za-z0-9_-]+$"
        client_secret:
          type: string
          maxLength: 160
          pattern: "^uphex_oauth_secret_[A-Za-z0-9_-]+$"
        code_verifier:
          type: string
          maxLength: 128
          pattern: "^[A-Za-z0-9._~-]+$"
        refresh_token:
          type: string
          maxLength: 128
          pattern: "^uphex_refresh_[A-Za-z0-9_-]+$"
    OAuthTokenResponse:
      type: object
      required: [access_token, refresh_token, token_type, expires_in, refresh_token_expires_in, scope]
      properties:
        access_token:
          type: string
          pattern: "^uphex_access_[A-Za-z0-9_-]+$"
        refresh_token:
          type: string
          pattern: "^uphex_refresh_[A-Za-z0-9_-]+$"
        token_type:
          type: string
          enum: [Bearer]
        expires_in:
          type: integer
          minimum: 1
          example: 3600
          description: Access token lifetime in seconds (one hour).
        refresh_token_expires_in:
          type: integer
          minimum: 1
          example: 2592000
          description: Refresh token lifetime in seconds (30 days). Each refresh issues a new access/refresh pair and revokes the refresh token that was used.
        scope:
          type: string
          description: Space-delimited scopes granted to this access token.
          example: "clients:read metrics:read"
    OAuthErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: OAuth-style error code returned by token and revocation endpoints.
          enum:
            - invalid_client
            - invalid_grant
            - invalid_request
            - rate_limited
            - unsupported_grant_type
    OAuthRevocationRequest:
      type: object
      required: [token]
      properties:
        token:
          type: string
          maxLength: 128
          pattern: "^uphex_(access|refresh)_[A-Za-z0-9_-]+$"
        client_id:
          type: string
          maxLength: 128
          pattern: "^uphex_oauth_[A-Za-z0-9_-]+$"
        client_secret:
          type: string
          maxLength: 160
          pattern: "^uphex_oauth_secret_[A-Za-z0-9_-]+$"
    MessageSendErrors:
      type: object
      required: [errors]
      description: |
        A send refused by crm-service, in the normalized downstream error list.
        The code and HTTP status are crm-service's (sc-105446), except crm
        `idempotency_key_reused`, which is `409` `idempotency_key_conflict`. Codes outside
        this list come back as the generic `downstream_rejected` /
        `downstream_unavailable`.
      properties:
        request_id:
          type: string
        errors:
          type: array
          items:
            type: object
            required: [code, title, detail, source]
            properties:
              code:
                type: string
                enum:
                  - invalid_params
                  - client_not_found
                  - lead_not_found
                  - lead_missing_phone
                  - lead_missing_email
                  - invalid_from_email
                  - ghl_routed_contact
                  - no_from_number_provisioned
                  - a2p_campaign_not_ready
                  - sms_suppressed
                  - email_suppressed
                  - sms_not_enabled
                  - insufficient_credits
                  - sending_domain_unavailable
                  - send_failed
                  - idempotency_key_in_progress
                  - idempotency_key_conflict
                  - external_id_conflict
              title:
                type: string
              detail:
                type: string
              source:
                type: string
                enum: [crm]
              reason:
                type: string
                description: Provider-side reason when known (for example the A2P or suppression reason).
              retryable:
                type: boolean
                description: On `send_failed`, `true` when retrying with the same key may succeed.
              lead_message_id:
                type: integer
                description: The recorded lead message for a failed or in-progress send.
      example:
        request_id: req_123
        errors:
          - code: no_from_number_provisioned
            title: The client has no SMS sender number. Provision a number in UpHex first.
            detail: The client has no SMS sender number. Provision a number in UpHex first.
            source: crm
    CursorMeta:
      type: object
      required: [resource, limit, next_cursor]
      properties:
        resource:
          type: string
          enum: [conversations, lead_messages]
        limit:
          type: integer
        next_cursor:
          type: string
          nullable: true
          description: Pass as `cursor` for the next page; `null` on the last page.
    Conversation:
      type: object
      properties:
        lead_id:
          type: integer
        client_id:
          type: integer
        reason:
          type: string
          enum: [waiting_on_team_reply, follow_up_window, new_lead]
        updated_at:
          type: string
          format: date-time
          description: Latest message time, or the lead's creation time when it has no messages.
        lead:
          type: object
          properties:
            id:
              type: integer
            full_name:
              type: string
              nullable: true
            email:
              type: string
              nullable: true
            phone:
              type: string
              nullable: true
            status:
              type: string
              nullable: true
            created_at:
              type: string
              format: date-time
              nullable: true
        latest_message:
          type: object
          nullable: true
          properties:
            direction:
              type: string
              enum: [inbound, outbound]
            channel:
              type: string
            body:
              type: string
              nullable: true
            subject:
              type: string
              nullable: true
            status:
              type: string
              nullable: true
            created_at:
              type: string
              format: date-time
    LeadMessage:
      type: object
      properties:
        id:
          type: integer
        lead_id:
          type: integer
        client_id:
          type: integer
        channel:
          type: string
          enum: [sms, email, voice, custom]
        channel_label:
          type: string
          nullable: true
          description: Display name of a custom channel (for example `CloseBot Chat`).
        direction:
          type: string
          enum: [inbound, outbound]
        status:
          type: string
          nullable: true
          description: Delivery status such as `queued`, `sent`, `delivered`, `failed`, `received`.
        body:
          type: string
          nullable: true
        subject:
          type: string
          nullable: true
        html:
          type: string
          nullable: true
        from_number:
          type: string
          nullable: true
        to_number:
          type: string
          nullable: true
        external_id:
          type: string
          nullable: true
          description: Your app's `external_id` for a custom message it logged; otherwise `null`.
        error_code:
          type: string
          nullable: true
        error_message:
          type: string
          nullable: true
        duration_seconds:
          type: integer
          nullable: true
        recording_url:
          type: string
          nullable: true
        occurred_at:
          type: string
          nullable: true
          description: The `occurred_at` sent when logging a custom message.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    LeadMessageResult:
      type: object
      required: [data, meta]
      properties:
        data:
          $ref: "#/components/schemas/LeadMessage"
        meta:
          type: object
          properties:
            resource:
              type: string
              enum: [lead_message]
            idempotent_replay:
              type: boolean
    LeadMessageCreateRequest:
      type: object
      required: [client_id, type, body]
      description: |
        Fields not listed for the chosen `type` are rejected with `400`.
      properties:
        client_id:
          type: integer
          minimum: 1
          description: Client that owns the lead. Must belong to the authorized agency.
        type:
          type: string
          enum: [sms, email, custom]
        body:
          type: string
          description: Message text (plain text for email; used as the HTML body when `html` is omitted).
        subject:
          type: string
          maxLength: 998
          description: Required for `email`.
        html:
          type: string
          description: Optional HTML body for `email`.
        from_email:
          type: string
          description: Optional From for `email` (`a@b.com` or `Name <a@b.com>`); defaults to the client's configured sender.
        direction:
          type: string
          enum: [inbound, outbound]
          description: Required for `custom`.
        channel_label:
          type: string
          maxLength: 40
          description: Required for `custom`.
        external_id:
          type: string
          maxLength: 200
          description: Optional for `custom`; your message id, unique per client. Without it the `Idempotency-Key` dedupes the log.
        occurred_at:
          type: string
          format: date-time
          description: Optional for `custom`; stored and returned (in UTC) as `occurred_at`.
    MessagingReadiness:
      type: object
      properties:
        client_id:
          type: integer
        sms:
          type: object
          properties:
            ready:
              type: boolean
            reason:
              type: string
              nullable: true
              enum: [no_from_number_provisioned, a2p_campaign_not_ready, null]
            detail:
              type: string
              nullable: true
              description: Raw messaging-service reason, for example `no_from_number`, `a2p_not_ready`, `shared_number_unverified`, or `uk_review_pending`.
            from_number:
              type: string
              nullable: true
            number_type:
              type: string
              nullable: true
        email:
          type: object
          properties:
            ready:
              type: boolean
            reason:
              type: string
              nullable: true
              enum: [sending_domain_unavailable, null]
            sending_domain:
              type: string
            sending_domain_scope:
              type: string
              enum: [client, agency, platform]
            sending_domain_status:
              type: string
              nullable: true
      example:
        client_id: 123
        sms:
          ready: false
          reason: a2p_campaign_not_ready
          detail: a2p_not_ready
          from_number: "+15555550100"
          number_type: local
        email:
          ready: true
          reason: null
          sending_domain: mg.example-agency.com
          sending_domain_scope: agency
          sending_domain_status: active
    PublicEnvelope:
      type: object
      required: [data, meta]
      properties:
        data:
          description: Public API resource payload normalized by uphex-api. Obvious downstream secret, token, password, and digest fields are redacted recursively, including token-like text inside otherwise safe string fields.
        meta:
          type: object
          description: Safe API-edge metadata. Downstream internal metadata is filtered, and preserved values are redacted and bounded.
          properties:
            resource:
              type: string
            page:
              type: integer
            per_page:
              type: integer
            total_entries:
              type: integer
            total_pages:
              type: integer
            start_date:
              type: string
              format: date
            end_date:
              type: string
              format: date
            group_by:
              type: string
            sort_by:
              type: string
            sort_dir:
              type: string
    PublicErrors:
      type: object
      required: [errors]
      properties:
        request_id:
          type: string
          description: Correlation id matching `X-Request-ID` for UpHex support.
        errors:
          oneOf:
            - type: object
              description: API-edge validation errors keyed by public field name or `base`.
              additionalProperties:
                type: array
                items:
                  type: string
              example:
                base: ["Invalid API credentials"]
            - type: array
              description: Normalized downstream service errors.
              items:
                type: object
                required: [code, title, detail, source]
                properties:
                  code:
                    type: string
                  title:
                    type: string
                  detail:
                    type: string
                    description: Developer-readable message normalized by uphex-api.
                  source:
                    type: string
    CalendarResourceMeta:
      type: object
      required: [resource]
      properties:
        resource:
          type: string
          enum: [calendars, free_slots, appointments, appointment]
    Calendar:
      type: object
      required: [id, client_id, name, timezone, status, provider, event_types]
      properties:
        id:
          type: integer
          format: int64
        client_id:
          type: integer
          format: int64
        name:
          type: string
        timezone:
          type: string
          example: America/Denver
        status:
          type: string
          example: active
        provider:
          type: string
          enum: [native]
        event_types:
          type: array
          items:
            $ref: "#/components/schemas/EventType"
    EventType:
      type: object
      required: [id, calendar_id, name, duration_minutes, kind, active, bookable]
      properties:
        id:
          type: integer
          format: int64
        calendar_id:
          type: integer
          format: int64
        name:
          type: string
        duration_minutes:
          type: integer
        kind:
          type: string
          enum: [solo, round_robin, collective, group]
        location_type:
          type: string
          nullable: true
        active:
          type: boolean
        bookable:
          type: boolean
          description: True when `POST /v1/appointments` can book this event type.
    FreeSlots:
      type: object
      required: [event_type_id, timezone, slots]
      properties:
        event_type_id:
          type: integer
          format: int64
        timezone:
          type: string
        slots:
          type: array
          items:
            type: object
            required: [starts_at, ends_at, timezone, remaining_capacity]
            properties:
              starts_at:
                type: string
                format: date-time
                example: "2026-11-05T16:00:00Z"
              ends_at:
                type: string
                format: date-time
              timezone:
                type: string
              remaining_capacity:
                type: integer
    Appointment:
      type: object
      required: [id, client_id, lead_id, calendar_id, event_type_id, provider, status, starts_at, ends_at, timezone]
      properties:
        id:
          type: string
          description: "`bk_<id>` for native bookings, `la_<id>` for synced provider appointments."
          example: bk_300
        client_id:
          type: integer
          format: int64
        lead_id:
          type: integer
          format: int64
        calendar_id:
          type: integer
          format: int64
          nullable: true
          description: Native calendar id; null for synced appointments.
        event_type_id:
          type: integer
          format: int64
          nullable: true
          description: Native event type id; null for synced appointments.
        title:
          type: string
          nullable: true
        provider:
          type: string
          enum: [native, ghl, calendly, google]
        status:
          type: string
          enum: [scheduled, pending, rescheduled, cancelled, no_show, showed]
          description: "`pending` awaits payment; `rescheduled` was replaced by a newer appointment."
        starts_at:
          type: string
          format: date-time
        ends_at:
          type: string
          format: date-time
          nullable: true
        timezone:
          type: string
          nullable: true
          description: The lead's timezone for native bookings; null for synced appointments.
        previous_appointment_id:
          type: string
          nullable: true
          description: Set on the appointment created by a reschedule.
    AppointmentEnvelope:
      type: object
      required: [data, meta]
      properties:
        data:
          $ref: "#/components/schemas/Appointment"
        meta:
          $ref: "#/components/schemas/CalendarResourceMeta"
  securitySchemes:
    ApiKeyBasic:
      type: http
      scheme: basic
      description: "Alternative to `X-API-Key`/`X-API-Secret`. Send `Authorization: Basic base64(<api_key>:<api_secret>)` using a Developer Portal API key and secret."
    ApiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: Use with `X-API-Secret`. Public API authentication headers must be sent at most once, must use generated UpHex credential prefixes, and must not be combined with bearer authentication. API key credentials sent through HTTP Basic authentication are bounded before base64 decoding and before lookup.
    ApiSecret:
      type: apiKey
      in: header
      name: X-API-Secret
      description: Required with `X-API-Key` for direct API-key authentication. Secrets are shown once in the portal, stored only as digests, and bounded before lookup.
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: Opaque OAuth access token or legacy API token
      description: "`Authorization` must be sent at most once. Bearer material and API key Basic credentials are bounded before verification, and bearer auth must not be combined with API key headers on protected public API routes."
