openapi: 3.1.0
# Hand-authored source of truth for API v1. NOT generated by @nestjs/swagger:
# v1 responses are plain interfaces in @outboundsync/models, not decorated DTO
# classes, so there is nothing for decorator-based generation to introspect.
# Keep this in sync with those interfaces — v1-openapi.contract.spec.ts fails
# if the two drift.
info:
  title: OutboundSync API
  version: "1.0.0"
  description: |
    Public REST API for OutboundSync account introspection, pipeline status,
    and contact prior-outreach enrichment for spreadsheet tools (ZoomInfo GTM
    Studio, Clay, Databar, Freckle, and similar).
    Authenticate with a Bearer token: `Authorization: Bearer osapi_<key>`.

    Machine-readable contract for API v1. Human reference:
    https://outboundsync.com/docs/api/v1
  contact:
    name: OutboundSync
    url: https://outboundsync.com
servers:
  - url: https://app.outboundsync.com
    description: Production
security:
  - bearerAuth: []
tags:
  - name: Discovery
    description: Auth-free OpenAPI document endpoints
  - name: Platform
    description: Public, unauthenticated platform health checks
  - name: Account
    description: Identity and pipeline readiness
  - name: Connections
    description: CRM connections
  - name: Sources
    description: Inbound sequencer paste URLs
  - name: Contacts
    description: Prior outreach and contact engagement lookups
paths:
  /health/live:
    get:
      operationId: getHealthLive
      tags: [Platform]
      summary: Platform liveness
      description: |
        Liveness probe. Returns 200 whenever the platform process is reachable
        and does no downstream checks. No API key required. Lives at the host
        root, not under /api/v1.
      security: []
      responses:
        "200":
          description: Platform process is up
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthLiveResponse"
  /health/ready:
    get:
      operationId: getHealthReady
      tags: [Platform]
      summary: Platform readiness
      description: |
        Readiness probe. Reports whether the platform can receive and sync
        events right now, with per-component checks. `GET /health` is an alias.
        Returns 200 for operational/partial_outage and 503 for a total outage.
        No API key required. Lives at the host root, not under /api/v1.
      security: []
      responses:
        "200":
          description: Readiness snapshot (operational or partial_outage)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthReadyResponse"
        "503":
          description: Total outage — every component check is in outage
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthReadyResponse"
  /api/v1/openapi.json:
    get:
      operationId: getOpenApiJson
      tags: [Discovery]
      summary: OpenAPI 3.1 document (JSON)
      description: Auth-free machine-readable API contract.
      security: []
      responses:
        "200":
          description: OpenAPI 3.1 document
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
  /api/v1/openapi.yaml:
    get:
      operationId: getOpenApiYaml
      tags: [Discovery]
      summary: OpenAPI 3.1 document (YAML)
      description: Auth-free machine-readable API contract (source form).
      security: []
      responses:
        "200":
          description: OpenAPI 3.1 document
          content:
            application/yaml:
              schema:
                type: string
  /api/v1/me:
    get:
      operationId: getMe
      tags: [Account]
      summary: API key identity and related links
      description: |
        Returns account, API key metadata, accessible CRM connections, and links
        to related v1 endpoints. Use as the bootstrap call after authentication.
      responses:
        "200":
          description: Identity and discovery links
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MeResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /api/v1/account/status:
    get:
      operationId: getAccountStatus
      tags: [Account]
      summary: Pipeline readiness
      description: |
        Point-in-time pipeline status across all connections the key can access.
        Authenticated-but-not-ready returns HTTP 200 with `ready: false`.
      responses:
        "200":
          description: Readiness snapshot
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AccountStatusResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /api/v1/connections:
    get:
      operationId: listConnections
      tags: [Connections]
      summary: List CRM connections
      description: CRM connections with embedded status and capability flags.
      responses:
        "200":
          description: Named list wrapper
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConnectionsResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /api/v1/contacts/outreach:
    get:
      operationId: getContactOutreach
      tags: [Contacts]
      summary: Prior outreach summary for a contact email and/or social profile
      description: |
        Aggregated prior engagement for contact identities across accessible CRM
        connections. Pass `email` and/or one or more `profileUrl` values (LinkedIn
        `/in/…` or `/pub/…`, or X/Twitter). At least one identity is required. At
        most 5 `profileUrl` values. All supplied identities are OR-unioned into
        one aggregate (intended for one contact row — do not mix unrelated people).

        Social-only sequencer events: HubSpot/Salesforce backfill `to_email` with
        the profile URL when email is missing — use `profileUrl` for those
        lookups. Prefer `email` when available (broader CRM + category coverage).

        Profile URL matching expands scheme/www/case/slash variants (scheme
        optional) and canonicalizes LinkedIn overlay/mwlite/country hosts to
        `/in|pub/{slug}` and X status URLs to `/{handle}`.

        Counts are deduped by inbound payload id **per event type × platform**
        (CRM retry duplicate logs). Designed for spreadsheet enrichment tools
        (ZoomInfo GTM Studio, Clay, Databar, Freckle, and similar).

        `everContacted` excludes open/click-only tracking and passive social
        views/likes. `found` is true when webhook log rows and/or a lead category
        signal exist. `doNotContact` covers hard signals only (bounce / unsubscribe).
        `blocklists` is a reserved CRM blocklist namespace — `evaluated` is always
        false in this release (do not treat `matched: false` as "not on a list").
        Company/domain rollup is a separate reserved path
        (`GET /api/v1/accounts/outreach?domain=`, not implemented) — do not pass a
        bare domain as `email` or `profileUrl` here.
        Timestamps are processing time (`webhook_logs.created_at`).

        Rate limit: 600 requests / 60 seconds per account (separate from the
        general v1 120/min limit).
      parameters:
        - name: email
          in: query
          required: false
          description: Contact email (case-insensitive normalize on input). Optional when profileUrl is provided.
          schema:
            type: string
            format: email
          example: jane@acme.com
        - name: profileUrl
          in: query
          required: false
          description: |
            LinkedIn (`/in/…` or `/pub/…`) or X/Twitter profile URL. Scheme is
            optional (e.g. `linkedin.com/in/jane`). Repeat the parameter or
            comma-separate to pass multiple (max 5). Optional when email is provided.
            Profile-URL matching resolves only for contacts synced through HubSpot or
            Salesforce; Attio/Close/HighLevel/Pipedrive social-only events are not
            matched by `profileUrl` — pass `email` for those.
          schema:
            type: array
            maxItems: 5
            items:
              type: string
          style: form
          explode: true
          example:
            - https://www.linkedin.com/in/jane-doe
            - https://x.com/jane
      responses:
        "200":
          description: Aggregated prior outreach for the identities
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ContactOutreachResponse"
              examples:
                found:
                  summary: Prior outreach found (email + profile URLs)
                  value:
                    email: jane@acme.com
                    query:
                      email: jane@acme.com
                      profileUrls:
                        - https://linkedin.com/in/jane-doe
                        - https://x.com/jane
                    found: true
                    summary:
                      everContacted: true
                      everEmailed: true
                      everOpened: true
                      everCalled: false
                      everSocialTouched: true
                      totalEvents: 47
                      firstTouchAt: "2025-11-02T14:01:00.000Z"
                      lastTouchAt: "2026-07-20T18:22:00.000Z"
                      daysSinceLastTouch: 9
                      lastEventType: EMAIL_REPLY
                      lastPlatform: smartlead
                    eventTypes:
                      - eventType: EMAIL_SENT
                        count: 12
                        firstAt: "2025-11-02T14:01:00.000Z"
                        lastAt: "2026-07-18T10:00:00.000Z"
                      - eventType: EMAIL_OPEN
                        count: 28
                        firstAt: "2025-11-02T15:00:00.000Z"
                        lastAt: "2026-07-19T12:00:00.000Z"
                      - eventType: EMAIL_REPLY
                        count: 1
                        firstAt: "2026-07-20T18:22:00.000Z"
                        lastAt: "2026-07-20T18:22:00.000Z"
                    platforms:
                      - platform: smartlead
                        count: 40
                        lastAt: "2026-07-20T18:22:00.000Z"
                        lastEventType: EMAIL_REPLY
                      - platform: heyreach
                        count: 7
                        lastAt: "2026-06-01T09:00:00.000Z"
                        lastEventType: MESSAGE_SENT
                    outcomes:
                      replied: true
                      lastReplyAt: "2026-07-20T18:22:00.000Z"
                      bounced: false
                      unsubscribed: false
                      lastCategoryName: Interested
                      lastCategoryAt: "2026-07-21T09:00:00.000Z"
                    doNotContact:
                      value: false
                      reasons: []
                    blocklists:
                      evaluated: false
                      matched: false
                      matches: []
                notFound:
                  summary: No prior outreach for the identities
                  value:
                    email: unknown@acme.com
                    query:
                      email: unknown@acme.com
                      profileUrls: []
                    found: false
                    summary:
                      everContacted: false
                      everEmailed: false
                      everOpened: false
                      everCalled: false
                      everSocialTouched: false
                      totalEvents: 0
                      firstTouchAt: null
                      lastTouchAt: null
                      daysSinceLastTouch: null
                      lastEventType: null
                      lastPlatform: null
                    eventTypes: []
                    platforms: []
                    outcomes:
                      replied: false
                      lastReplyAt: null
                      bounced: false
                      unsubscribed: false
                      lastCategoryName: null
                      lastCategoryAt: null
                    doNotContact:
                      value: false
                      reasons: []
                    blocklists:
                      evaluated: false
                      matched: false
                      matches: []
                profileOnly:
                  summary: Social profile lookup (no email)
                  value:
                    email: null
                    query:
                      email: null
                      profileUrls:
                        - https://www.linkedin.com/in/jane-doe
                    found: true
                    summary:
                      everContacted: true
                      everEmailed: false
                      everOpened: false
                      everCalled: false
                      everSocialTouched: true
                      totalEvents: 3
                      firstTouchAt: "2026-06-01T09:00:00.000Z"
                      lastTouchAt: "2026-06-10T12:00:00.000Z"
                      daysSinceLastTouch: 49
                      lastEventType: MESSAGE_SENT
                      lastPlatform: heyreach
                    eventTypes:
                      - eventType: MESSAGE_SENT
                        count: 3
                        firstAt: "2026-06-01T09:00:00.000Z"
                        lastAt: "2026-06-10T12:00:00.000Z"
                    platforms:
                      - platform: heyreach
                        count: 3
                        lastAt: "2026-06-10T12:00:00.000Z"
                        lastEventType: MESSAGE_SENT
                    outcomes:
                      replied: false
                      lastReplyAt: null
                      bounced: false
                      unsubscribed: false
                      lastCategoryName: null
                      lastCategoryAt: null
                    doNotContact:
                      value: false
                      reasons: []
                    blocklists:
                      evaluated: false
                      matched: false
                      matches: []
        "400":
          description: Missing identities or invalid email/profileUrl
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
              examples:
                missingIdentity:
                  summary: Neither email nor profileUrl provided
                  value:
                    statusCode: 400
                    message: 'Provide at least one identity: query parameter "email" and/or "profileUrl"'
                    error: Bad Request
                invalidProfileUrl:
                  summary: profileUrl is not LinkedIn /in|/pub or X
                  value:
                    statusCode: 400
                    message: 'Query parameter "profileUrl" must be a LinkedIn (/in/… or /pub/…) or X/Twitter profile URL'
                    error: Bad Request
                tooManyProfileUrls:
                  summary: More than 5 profileUrl values
                  value:
                    statusCode: 400
                    message: 'Query parameter "profileUrl" accepts at most 5 values'
                    error: Bad Request
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /api/v1/sources:
    get:
      operationId: listSources
      tags: [Sources]
      summary: List inbound sources
      description: |
        Inbound sources with the URL to paste into a sequencer, platform,
        config summary, and destination bindings.
      responses:
        "200":
          description: Named list wrapper
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SourcesResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: osapi
      description: |
        OutboundSync API key. Pass as `Authorization: Bearer osapi_<key>`.
        Keys are created in the OutboundSync dashboard.
  responses:
    Unauthorized:
      description: Missing, invalid, or revoked API key
      headers:
        WWW-Authenticate:
          schema:
            type: string
            example: Bearer realm="OutboundSync API"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"
    Forbidden:
      description: Valid key but no API-enabled connections (`canUseApi`)
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"
    TooManyRequests:
      description: Per-account fixed-window rate limit exceeded
      headers:
        Retry-After:
          schema:
            type: string
            description: Seconds until the client may retry
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"
  schemas:
    # Health responses are intentionally forward-compatible (additionalProperties:
    # true): new component checks may appear under `checks` without a breaking
    # change, so these are not part of the additionalProperties:false drift guard.
    HealthLiveResponse:
      type: object
      additionalProperties: true
      required: [schema_version, status]
      properties:
        schema_version:
          type: integer
          description: Version of this response contract. Currently 2.
        status:
          type: string
          description: Always "operational" when the process is reachable.
    HealthReadyResponse:
      type: object
      additionalProperties: true
      required: [schema_version, status]
      properties:
        schema_version:
          type: integer
          description: Version of this response contract. Currently 2.
        status:
          type: string
          enum: [operational, partial_outage, major_outage]
        checks:
          type: object
          additionalProperties:
            type: string
            enum: [operational, outage]
          description: |
            Per-component status. Components present depend on the deployment
            (production/staging include the destination workers; local omits
            them). Treat unknown keys as forward-compatible.
    ApiError:
      type: object
      additionalProperties: false
      required: [statusCode, message, error]
      properties:
        statusCode:
          type: integer
        message:
          type: string
        error:
          type: string
    MeRelatedLink:
      type: object
      additionalProperties: false
      required: [href, description]
      properties:
        href:
          type: string
        description:
          type: string
    MeLinks:
      type: object
      additionalProperties: false
      required:
        [
          accountStatus,
          accountMetrics,
          connections,
          contactsOutreach,
          destinations,
          requests,
          destinationsReplyRelays,
          sources,
          syncs,
          documentation,
          openapi,
        ]
      properties:
        accountStatus:
          $ref: "#/components/schemas/MeRelatedLink"
        accountMetrics:
          $ref: "#/components/schemas/MeRelatedLink"
        connections:
          $ref: "#/components/schemas/MeRelatedLink"
        contactsOutreach:
          $ref: "#/components/schemas/MeRelatedLink"
        destinations:
          $ref: "#/components/schemas/MeRelatedLink"
        requests:
          $ref: "#/components/schemas/MeRelatedLink"
        destinationsReplyRelays:
          $ref: "#/components/schemas/MeRelatedLink"
        sources:
          $ref: "#/components/schemas/MeRelatedLink"
        syncs:
          $ref: "#/components/schemas/MeRelatedLink"
        documentation:
          $ref: "#/components/schemas/MeRelatedLink"
        openapi:
          $ref: "#/components/schemas/MeRelatedLink"
    MeAccount:
      type: object
      additionalProperties: false
      required: [id, email]
      properties:
        id:
          type: integer
        email:
          type: ["string", "null"]
    MeApiKey:
      type: object
      additionalProperties: false
      required: [name, scopes, connectionScope, connectionId]
      properties:
        name:
          type: string
        scopes:
          type: array
          items:
            type: string
        connectionScope:
          type: string
          enum: [account, connection]
        connectionId:
          type: ["integer", "null"]
          description: |
            Set when connectionScope is connection; null for account-scoped keys.
            Same as connections[].id when scoped.
    MeConnection:
      type: object
      additionalProperties: false
      required: [id, crm, organizationId, organizationDomain]
      properties:
        id:
          type: integer
        crm:
          type: string
        organizationId:
          type: ["string", "null"]
        organizationDomain:
          type: ["string", "null"]
    MeResponse:
      type: object
      additionalProperties: false
      required: [account, apiKey, connections, links]
      properties:
        account:
          $ref: "#/components/schemas/MeAccount"
        apiKey:
          $ref: "#/components/schemas/MeApiKey"
        connections:
          type: array
          items:
            $ref: "#/components/schemas/MeConnection"
        links:
          $ref: "#/components/schemas/MeLinks"
    BlockerCode:
      type: string
      enum:
        - crm_disconnected
        - sync_not_enabled
        - no_sources
    WarningCode:
      type: string
      enum:
        - destinations_not_configured
        - blocklists_not_configured
        - blocklists_error
    StatusBlocker:
      type: object
      additionalProperties: false
      required: [code, connectionId, crm, message, remediation, docUrl]
      properties:
        code:
          $ref: "#/components/schemas/BlockerCode"
        connectionId:
          type: integer
        crm:
          type: string
        message:
          type: string
        remediation:
          type: string
        docUrl:
          type: string
          format: uri
    StatusWarning:
      type: object
      additionalProperties: false
      required: [code, connectionId, crm, message, remediation, docUrl]
      properties:
        code:
          $ref: "#/components/schemas/WarningCode"
        connectionId:
          type: integer
        crm:
          type: string
        message:
          type: string
        remediation:
          type: string
        docUrl:
          type: string
          format: uri
    CrmConnectionStatus:
      type: object
      additionalProperties: false
      required: [status, crm, organizationId, organizationDomain]
      properties:
        status:
          type: string
          enum: [ready, disconnected]
        crm:
          type: string
        organizationId:
          type: ["string", "null"]
        organizationDomain:
          type: ["string", "null"]
    SourcesStatus:
      type: object
      additionalProperties: false
      required: [status, count, platforms]
      properties:
        status:
          type: string
          enum: [ready, not_configured, disabled]
        count:
          type: integer
        platforms:
          type: array
          items:
            type: string
    DestinationsStatus:
      type: object
      additionalProperties: false
      required: [status, count, eventTypes, forwardingCount, replyRelayCount]
      properties:
        status:
          type: string
          enum: [ready, not_configured, disabled]
        count:
          type: integer
          description: Distinct forwarding URLs + bound reply relays.
        eventTypes:
          type: array
          items:
            type: string
        forwardingCount:
          type: integer
        replyRelayCount:
          type: integer
    BlocklistsStatus:
      type: object
      additionalProperties: false
      required: [status, enabledCount, lastError, lastFetchedAt]
      properties:
        status:
          type: string
          enum: [ready, not_configured, disabled, error]
        enabledCount:
          type: integer
        lastError:
          type: ["string", "null"]
        lastFetchedAt:
          type: ["string", "null"]
          format: date-time
    ConnectionStatus:
      type: object
      additionalProperties: false
      required:
        - connectionId
        - crm
        - ready
        - crmConnection
        - sources
        - destinations
        - blocklists
      properties:
        connectionId:
          type: integer
        crm:
          type: string
        ready:
          type: boolean
        crmConnection:
          $ref: "#/components/schemas/CrmConnectionStatus"
        sources:
          $ref: "#/components/schemas/SourcesStatus"
        destinations:
          $ref: "#/components/schemas/DestinationsStatus"
        blocklists:
          $ref: "#/components/schemas/BlocklistsStatus"
    AccountStatusResponse:
      type: object
      additionalProperties: false
      required: [ready, checkedAt, connections, blockers, warnings]
      properties:
        ready:
          type: boolean
        checkedAt:
          type: string
          format: date-time
        connections:
          type: array
          items:
            $ref: "#/components/schemas/ConnectionStatus"
        blockers:
          type: array
          items:
            $ref: "#/components/schemas/StatusBlocker"
        warnings:
          type: array
          items:
            $ref: "#/components/schemas/StatusWarning"
    ConnectionCapabilities:
      type: object
      additionalProperties: false
      required: [sync, destinations, blocklists]
      properties:
        sync:
          type: boolean
        destinations:
          type: boolean
        blocklists:
          type: boolean
    ConnectionResource:
      type: object
      additionalProperties: false
      required:
        - id
        - crm
        - status
        - organizationId
        - organizationDomain
        - capabilities
        - createdAt
      properties:
        id:
          type: integer
        crm:
          type: string
        status:
          type: string
          enum: [ready, disconnected]
        organizationId:
          type: ["string", "null"]
        organizationDomain:
          type: ["string", "null"]
        capabilities:
          $ref: "#/components/schemas/ConnectionCapabilities"
        createdAt:
          type: string
          format: date-time
    ConnectionsResponse:
      type: object
      additionalProperties: false
      required: [connections]
      properties:
        connections:
          type: array
          items:
            $ref: "#/components/schemas/ConnectionResource"
    SourceConfig:
      type: object
      additionalProperties: false
      required:
        - createOrUpdateCompany
        - createOrUpdateTask
        - assignContactOwner
        - salesforceObjectType
      properties:
        createOrUpdateCompany:
          type: boolean
        createOrUpdateTask:
          type: boolean
        assignContactOwner:
          type: boolean
        salesforceObjectType:
          type: ["string", "null"]
    SourceDestination:
      type: object
      additionalProperties: false
      required: [url, description, eventTypes, isDelayed]
      properties:
        url:
          type: string
          format: uri
        description:
          type: string
        eventTypes:
          type: array
          items:
            type: string
        isDelayed:
          type: boolean
    SourceReplyRelay:
      type: object
      additionalProperties: false
      required: [id, description, ccEmail, delayMinutes]
      properties:
        id:
          type: integer
        description:
          type: string
        ccEmail:
          type: string
        delayMinutes:
          type: integer
    Source:
      type: object
      additionalProperties: false
      required:
        - id
        - url
        - platform
        - connectionId
        - crm
        - config
        - destinations
        - replyRelay
        - createdAt
      properties:
        id:
          type: integer
        url:
          type: string
          format: uri
          description: Paste URL for the sequencer (includes webhook ingress code).
        platform:
          type: string
        connectionId:
          type: integer
        crm:
          type: string
        config:
          $ref: "#/components/schemas/SourceConfig"
        destinations:
          type: array
          items:
            $ref: "#/components/schemas/SourceDestination"
        replyRelay:
          oneOf:
            - $ref: "#/components/schemas/SourceReplyRelay"
            - type: "null"
        createdAt:
          type: string
          format: date-time
    SourcesResponse:
      type: object
      additionalProperties: false
      required: [sources]
      properties:
        sources:
          type: array
          items:
            $ref: "#/components/schemas/Source"
    ContactOutreachEventTypeSummary:
      type: object
      additionalProperties: false
      required: [eventType, count, firstAt, lastAt]
      properties:
        eventType:
          type: string
        count:
          type: integer
        firstAt:
          type: string
          format: date-time
        lastAt:
          type: string
          format: date-time
    ContactOutreachPlatformSummary:
      type: object
      additionalProperties: false
      required: [platform, count, lastAt, lastEventType]
      properties:
        platform:
          type: string
        count:
          type: integer
        lastAt:
          type: string
          format: date-time
        lastEventType:
          type: string
    ContactOutreachSummary:
      type: object
      additionalProperties: false
      required:
        - everContacted
        - everEmailed
        - everOpened
        - everCalled
        - everSocialTouched
        - totalEvents
        - firstTouchAt
        - lastTouchAt
        - daysSinceLastTouch
        - lastEventType
        - lastPlatform
      properties:
        everContacted:
          type: boolean
          description: True when the contact was emailed, called, socially touched, or replied — excludes open/click-only.
        everEmailed:
          type: boolean
        everOpened:
          type: boolean
        everCalled:
          type: boolean
        everSocialTouched:
          type: boolean
        totalEvents:
          type: integer
        firstTouchAt:
          type: ["string", "null"]
          format: date-time
        lastTouchAt:
          type: ["string", "null"]
          format: date-time
        daysSinceLastTouch:
          type: ["integer", "null"]
          minimum: 0
          description: |
            Whole days since lastTouchAt (floor of elapsed ms / 86400000).
            Null when lastTouchAt is null. Useful for cadence filters
            (e.g. keep rows where daysSinceLastTouch is null or >= 90).
        lastEventType:
          type: ["string", "null"]
        lastPlatform:
          type: ["string", "null"]
    ContactOutreachOutcomes:
      type: object
      additionalProperties: false
      required:
        - replied
        - lastReplyAt
        - bounced
        - unsubscribed
        - lastCategoryName
        - lastCategoryAt
      properties:
        replied:
          type: boolean
        lastReplyAt:
          type: ["string", "null"]
          format: date-time
        bounced:
          type: boolean
        unsubscribed:
          type: boolean
        lastCategoryName:
          type: ["string", "null"]
        lastCategoryAt:
          type: ["string", "null"]
          format: date-time
    ContactOutreachDoNotContact:
      type: object
      additionalProperties: false
      required: [value, reasons]
      properties:
        value:
          type: boolean
        reasons:
          type: array
          items:
            type: string
          description: Hard DNC reasons only — bounced, unsubscribed.
    ContactOutreachBlocklistMatch:
      type: object
      additionalProperties: false
      required: [listId, listName, blockType, matchedValue]
      properties:
        listId:
          type: integer
        listName:
          type: ["string", "null"]
        blockType:
          type: string
          enum: [ADDRESS, DOMAIN]
        matchedValue:
          type: string
    ContactOutreachBlocklists:
      type: object
      additionalProperties: false
      required: [evaluated, matched, matches]
      description: |
        Reserved CRM blocklist namespace. In this release `evaluated` is always false,
        `matched` is always false, and `matches` is always empty. Do not treat that as
        "not on a blocklist" — reverse lookup is not run until a later release.
      properties:
        evaluated:
          type: boolean
          description: True only when CRM blocklist reverse-lookup has been run for this request.
        matched:
          type: boolean
        matches:
          type: array
          items:
            $ref: "#/components/schemas/ContactOutreachBlocklistMatch"
    ContactOutreachQuery:
      type: object
      additionalProperties: false
      required: [email, profileUrls]
      properties:
        email:
          type: ["string", "null"]
          format: email
        profileUrls:
          type: array
          items:
            type: string
    ContactOutreachResponse:
      type: object
      additionalProperties: false
      required:
        - email
        - query
        - found
        - summary
        - eventTypes
        - platforms
        - outcomes
        - doNotContact
        - blocklists
      properties:
        email:
          type: ["string", "null"]
          format: email
          description: Deprecated alias of query.email — kept for spreadsheet connector field mapping.
        query:
          $ref: "#/components/schemas/ContactOutreachQuery"
        found:
          type: boolean
          description: True when webhook log rows and/or a lead category signal exist for the identities.
        summary:
          $ref: "#/components/schemas/ContactOutreachSummary"
        eventTypes:
          type: array
          items:
            $ref: "#/components/schemas/ContactOutreachEventTypeSummary"
        platforms:
          type: array
          items:
            $ref: "#/components/schemas/ContactOutreachPlatformSummary"
        outcomes:
          $ref: "#/components/schemas/ContactOutreachOutcomes"
        doNotContact:
          $ref: "#/components/schemas/ContactOutreachDoNotContact"
        blocklists:
          $ref: "#/components/schemas/ContactOutreachBlocklists"
