openapi: 3.0.3
info:
  title: Outvelo API
  version: "1.1.0"
  termsOfService: https://outvelo.com/terms
  contact:
    name: Outvelo Support
    email: yash@outvelo.com
    url: https://outvelo.com
  license:
    name: Proprietary, Outvelo Terms of Service
    url: https://outvelo.com/terms
  description: |
    Programmatic access to campaigns, prospects, conversations, accounts, and
    webhooks. Authenticate with an API key minted from Settings → API keys.

    ```bash
    curl https://api.outvelo.com/api/v1/campaigns \
      -H "Authorization: Bearer ak_..."
    ```

    **Scopes**

    | Scope | Allows |
    | --- | --- |
    | `read` | All `GET` endpoints |
    | `write` | `read` + create/update + add leads + send messages + manage webhooks |
    | `admin` | `write` + destructive ops (delete campaign / lead list, bulk prospect updates) |

    **Response envelope**: every response wraps the payload:

    ```json
    {
      "data": <object | array>,
      "meta": { "nextCursor": "...", "total": 123 },
      "error": { "code": "not_found", "message": "..." }
    }
    ```

    `meta` appears on paginated collections. `error` is mutually exclusive with
    `data`.

    **Pagination**

    List endpoints accept `limit` (page size) and `cursor` query parameters.
    Each paged response returns `meta.nextCursor`; pass that value back as
    `cursor` to fetch the next page. When `meta.nextCursor` is empty or absent,
    you've reached the end. `meta.total` carries the full count when available.

    ```bash
    # first page
    curl "https://api.outvelo.com/api/v1/prospects?limit=50" \
      -H "Authorization: Bearer ak_..."
    # next page, pass the previous response's meta.nextCursor
    curl "https://api.outvelo.com/api/v1/prospects?limit=50&cursor=<nextCursor>" \
      -H "Authorization: Bearer ak_..."
    ```

    **Errors**

    Errors use the same envelope with a stable, machine-readable `code` you can
    branch on (the HTTP status tells you the category):

    | Status | `code` | When |
    | --- | --- | --- |
    | 400 | `invalid_request` | Malformed body, bad query params, or validation failure |
    | 401 | `unauthorized` | Missing or invalid API key |
    | 403 | `forbidden` | The key lacks the scope required for this operation |
    | 404 | `not_found` | Resource doesn't exist in this workspace |
    | 409 | `conflict` | Duplicate or conflicting state (e.g. already exists) |
    | 429 | `rate_limited` | Too many requests, see Rate limiting below |

    **Rate limiting**

    Each API key is capped at a per-second request rate. The default for new
    keys is **10 requests/second** (raisable per-key in Settings → API keys).

    Every response includes:

    | Header | Meaning |
    | --- | --- |
    | `X-RateLimit-Limit` | Configured cap for this key (requests/second) |
    | `X-RateLimit-Remaining` | Approximate remaining requests in the current 1-second window |
    | `X-RateLimit-Reset` | Unix seconds when the window resets |

    If you exceed the limit you get `429 Too Many Requests` with a
    `Retry-After: 1` header. Back off and try again on the next second.

    **Idempotency**

    On writes (POST/PATCH/PUT/DELETE) you can pass `Idempotency-Key:
    <uuid>` to make retries safe. The first request runs normally; any
    subsequent request with the same key + path returns the original
    response without re-executing. Cache window: 24h. Replayed responses
    include the header `Idempotent-Replayed: true`.

servers:
  - url: https://api.outvelo.com/api/v1
    description: Production

security:
  - bearerAuth: []

tags:
  - name: Campaigns
    description: Create and manage outreach campaigns, their leads, funnel, and activity timeline.
  - name: Prospects
    description: Leads that are actively running through a campaign sequence, search, inspect, and update their state.
  - name: Leads
    description: |
      Push leads in batches before assigning them to campaigns. Lead lists are
      the workspace's pre-campaign pool, many agents drop new leads here
      first, then create campaigns that draw from them.
  - name: Lead lists
    description: Manage the pre-campaign lead pools and the leads within them.
  - name: Custom variables
    description: |
      Your own personalization tokens, on top of the built-in ones.

      **Two halves, worth keeping straight.** This endpoint group manages the
      *declarations* (what variables exist, their label, and the default value
      used when a lead has none). The *values* are per-lead and travel with the
      leads themselves, via the `custom` object on the lead endpoints.

      You do **not** need to declare a variable before sending values for it: a
      key inside `custom` is registered automatically on import.

      **On this API, custom values must go inside the `custom` object.** A
      loose unrecognized top-level key is ignored rather than promoted to a
      variable, so a typo or a field we don't consume can't quietly create one.
      (The CSV upload in the app is the exception: there, arbitrary extra
      columns are the point, and the UI shows you which ones will be created
      before you import.) Declaring is worth
      doing when you want a **default value**, which is what stops a
      partially-covered list from falling back to the generic message for every
      lead that's missing the field.

      **Template variables reference**

      Message bodies substitute `{{token}}`. Built-in tokens, always available:

      | Token | Value |
      |---|---|
      | `{{firstName}}` | First word of the prospect's full name |
      | `{{lastName}}` | Remainder of the full name |
      | `{{fullName}}` | Prospect's full name |
      | `{{company}}` | Prospect's company |
      | `{{jobTitle}}` (or `{{position}}`) | Prospect's role |
      | `{{location}}` | Prospect's location |
      | `{{headline}}` | LinkedIn headline |
      | `{{email}}` | Prospect's email, if enriched |
      | `{{senderFirstName}}` / `{{senderLastName}}` / `{{senderName}}` / `{{senderEmail}}` | The sending LinkedIn account |

      `snake_case` and all-lowercase spellings of the above also resolve
      (`{{first_name}}`, `{{firstname}}`). Custom variables are referenced the
      same way, by their token: `{{recent_funding}}`.

      **What happens when a value is missing**

      - Variable is registered but this lead has no value, and it has a
        **default** → the default is substituted.
      - Registered, no value, no default → the step's **fallback message** is
        sent instead of the body. This is why setting a default, or a good
        fallback, matters.
      - Token isn't registered at all (typo, deleted variable) → the token is
        **removed** from the message and the surrounding spacing tidied. A raw
        `{{token}}` is never delivered to a recipient.
  - name: Conversations
    description: Read inbox threads and send LinkedIn messages to prospects.
  - name: Accounts
    description: Inspect connected LinkedIn accounts and manage their schedules, daily limits, and health.
  - name: Webhooks
    description: |
      Subscribe URLs to outreach events and receive signed POSTs instead of
      polling. Delivery is asynchronous and retried with backoff. Managing
      webhooks (create / update / delete / test / rotate) needs a `write` scope.

      **Signing & verification**

      Every delivery is signed. The `X-Signature` header carries
      `base64(HMAC-SHA256(raw_body, secret))`, and `X-Event-Id` carries a stable
      event id you can use to dedupe retries.

      Verifying the signature is **optional but strongly recommended**: it's the
      only way to confirm a POST genuinely came from Outvelo (the receiver URL is
      otherwise public and anyone could call it) and that the body wasn't
      tampered with. To verify, compute the HMAC of the exact raw request body
      with your webhook secret and compare it, in constant time, to the
      `X-Signature` header:

      ```python
      import hmac, hashlib, base64
      expected = base64.b64encode(
          hmac.new(secret.encode(), raw_body, hashlib.sha256).digest()
      ).decode()
      ok = hmac.compare_digest(expected, request.headers["X-Signature"])
      ```

      **The secret**

      The signing secret is returned **only once**: in the create response, and
      again only when you rotate it. It is never included in list/get responses.
      If you lose it, call rotate-secret to mint a new one; the old secret stops
      working immediately, so update your receiver right away.

      Receiver URLs must be HTTPS and publicly resolvable.
  - name: Stats
    description: Workspace + per-resource roll-ups for agent-built dashboards.

paths:
  /workspace/stats:
    get:
      tags: [Stats]
      operationId: getWorkspaceStats
      summary: Workspace-wide rollup
      description: |
        Aggregates `requests_sent`, `requests_accepted`, `replied_count`,
        and `profiles_touched` across every campaign in the workspace
        + derived `acceptance` and `reply` rates.

        Requires scope: `read`.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/WorkspaceStats" }
              example:
                data:
                  totals:
                    campaigns: 7
                    active_campaigns: 3
                    requests_sent: 1840
                    requests_accepted: 612
                    replied_count: 143
                    profiles_touched: 2104
                    total_prospects: 1980
                  rates:
                    acceptance: 0.3326
                    reply: 0.2337
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /campaigns/{id}/stats:
    parameters:
      - $ref: "#/components/parameters/IdPath"
    get:
      tags: [Stats]
      operationId: getCampaignStats
      summary: Campaign stats + funnel
      description: "Requires scope: `read`."
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/CampaignStats" }
              example:
                data:
                  stats:
                    requests_sent: 420
                    requests_accepted: 138
                    replied_count: 31
                    profiles_touched: 480
                    total_prospects: 500
                  funnel:
                    perStep: [500, 412, 138, 96, 31]
                    perNode: { connection_request: 500, follow_up: 412 }
                    perEnd: { completed: 96, replied: 31 }
                    replied: 31
                    bounced: 8
                    paused: 4
                    completed: 96
                    total: 500
                  status: active
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /accounts/{id}/stats:
    parameters:
      - $ref: "#/components/parameters/IdPath"
    get:
      tags: [Stats]
      operationId: getAccountStats
      summary: Account's today usage by action
      description: "Requires scope: `read`."
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/AccountStats" }
              example:
                data:
                  account: acc_8f21c0
                  usage:
                    - actionType: connection_invite
                      used: 14
                      limit: 22
                      range: { min: 20, max: 25 }
                    - actionType: follow_up_message
                      used: 9
                      limit: 28
                      range: { min: 25, max: 30 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /accounts/{id}/schedule:
    parameters:
      - $ref: "#/components/parameters/IdPath"
    get:
      tags: [Accounts]
      operationId: getAccountSchedule
      summary: Read working-hours schedule
      description: "Requires scope: `read`."
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/AccountSchedule" }
              example:
                data:
                  schedule:
                    timezone: America/New_York
                    startTime: "09:00"
                    endTime: "21:00"
                    days: [1, 2, 3, 4, 5]
                    skipRandomDay: false
                    skipDates: ["2026-07-04"]
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Accounts]
      operationId: updateAccountSchedule
      summary: Replace working-hours schedule
      description: "Requires scope: `write`."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AccountScheduleUpdate" }
            example:
              schedule:
                timezone: America/New_York
                startTime: "09:00"
                endTime: "21:00"
                days: [1, 2, 3, 4, 5]
                skipRandomDay: false
                skipDates: ["2026-07-04"]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/AccountSchedule" }
              example:
                data:
                  schedule:
                    timezone: America/New_York
                    startTime: "09:00"
                    endTime: "21:00"
                    days: [1, 2, 3, 4, 5]
                    skipRandomDay: false
                    skipDates: ["2026-07-04"]
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /accounts/{id}/daily-limits:
    parameters:
      - $ref: "#/components/parameters/IdPath"
    get:
      tags: [Accounts]
      operationId: getAccountDailyLimits
      summary: Read per-action daily limits
      description: "Requires scope: `read`."
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/AccountDailyLimits" }
              example:
                data:
                  limits:
                    connection_invite: { min: 20, max: 25 }
                    follow_up_message: { min: 25, max: 30 }
                    inmail: { min: 0, max: 5 }
                    profile_view: { min: 22, max: 27 }
                  usage:
                    - actionType: connection_invite
                      used: 14
                      limit: 22
                      range: { min: 20, max: 25 }
                      weekly: { used: 118, cap: 200, remaining: 82, exhausted: false, dailyCeiling: 28 }
                  safeMode: false
                  platform:
                    tier: sales_navigator
                    tierLabel: Sales Navigator
                    tierAsOf: "2026-07-31T09:14:03Z"
                    noteCharLimit: 300
                    guidance:
                      - action: connection_invite
                        safeDaily: 28
                        safeWeekly: 200
                        note: "LinkedIn counts invites per week, not per day."
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Accounts]
      operationId: updateAccountDailyLimits
      summary: Replace per-action daily limits
      description: |
        Replace the per-action daily ranges, or toggle Shield (safe) mode.

        If `safeMode` is present it takes precedence: it flips the Shield-mode
        lock (enabling also resets every action to a conservative preset) and any
        `limits` in the body are ignored. Otherwise `limits` is required.

        Requires scope: `write`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AccountDailyLimitsUpdate" }
            examples:
              setLimits:
                summary: Set explicit ranges
                value:
                  limits:
                    connection_invite: { min: 20, max: 25 }
                    follow_up_message: { min: 25, max: 30 }
              toggleSafeMode:
                summary: Turn on Shield mode
                value:
                  safeMode: true
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/AccountDailyLimits" }
              example:
                data:
                  limits:
                    connection_invite: { min: 20, max: 25 }
                    follow_up_message: { min: 25, max: 30 }
                  safeMode: false
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /webhooks/{id}/test:
    parameters:
      - $ref: "#/components/parameters/IdPath"
    post:
      tags: [Webhooks]
      operationId: testWebhook
      summary: Fire a synthetic test event
      description: |
        Sends a `webhook.test` event to the registered URL so receivers
        can verify HMAC + connectivity before relying on the real event
        stream. The webhook row's `last_fired_at` / `last_failure` are
        updated as if it were a real delivery. Takes no request body.

        Requires scope: `write`.
      responses:
        "200":
          description: Fired
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/WebhookTestResult" }
              example:
                data:
                  ok: true
                  webhookId: wh_5b2a91
                  url: https://example.com/hooks/outvelo
                  note: "Fired synthetic event 'webhook.test', check your receiver and the webhook row's last_fired_at / last_failure for the result."
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /webhooks/{id}/rotate-secret:
    parameters:
      - $ref: "#/components/parameters/IdPath"
    post:
      tags: [Webhooks]
      operationId: rotateWebhookSecret
      summary: Rotate the signing secret
      description: |
        Mints a new signing secret and returns it ONCE in the response
        (`secret`). The previous secret is invalidated immediately, update
        your receiver's signature verification to the new secret right away or
        deliveries will fail to verify. Use this if the secret was lost or
        leaked. Takes no request body.

        Requires scope: `write`.
      responses:
        "200":
          description: Rotated (response includes the new `secret` once)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/WebhookWithSecret" }
              example:
                data:
                  id: wh_5b2a91
                  workspaceId: ws_3c10
                  name: Push to Outvelo - P1
                  url: https://example.com/hooks/outvelo
                  events: ["lead.replied", "connection.accepted"]
                  active: true
                  createdBy: jane@acme.com
                  createdAt: "2026-06-01T09:14:22Z"
                  updatedAt: "2026-06-24T11:02:10Z"
                  secret: whsec_4f9a1c2b8e3d7a60
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /me:
    get:
      tags: [Accounts]
      operationId: getMe
      summary: Introspect the calling API key
      description: |
        Returns the calling key's identity (id, name, scope, rateLimit) and
        the workspace it's scoped to. SDKs use this to verify a key works
        and surface "connected to <workspace>" UI.

        Requires scope: `read`.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Me" }
              example:
                data:
                  apiKey:
                    id: key_91ab33
                    name: Production agent
                    prefix: ak_live_a1b2
                    scope: write
                    rateLimit: 10
                    lastUsedAt: "2026-06-24T10:59:01Z"
                  workspace:
                    id: ws_3c10
                    name: Acme Outbound
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /prospects:
    get:
      tags: [Prospects]
      operationId: listProspects
      summary: Workspace-wide prospect search
      description: |
        Returns prospects across every campaign in the workspace, with
        cursor pagination + filters. The filters match the app's Prospects
        page. `meta.total` is the total matching the filters.

        Requires scope: `read`.
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - { in: query, name: q, schema: { type: string }, description: "Case-insensitive search on name/title/company/email. % and _ match literally." }
        - { in: query, name: campaignId, schema: { type: string } }
        - { in: query, name: accountId, schema: { type: string }, description: "Sending LinkedIn account" }
        - { in: query, name: addedVia, schema: { type: string, enum: [app, api, mcp, integration, unknown] }, description: "How the prospect was added. unknown = added before this was tracked" }
        - { in: query, name: state, schema: { type: string, enum: [active, paused, completed, bounced, replied, suppressed] }, description: "suppressed = skipped because another campaign already owns the lead" }
        - { in: query, name: blocklisted, schema: { type: string, enum: ["true", "false"] } }
        - { in: query, name: replied, schema: { type: string, enum: ["true", "false"] }, description: "true = state replied, false = state is anything else" }
        - { in: query, name: minStep, schema: { type: integer, minimum: 0 }, description: "current step >= minStep (1 = contacted)" }
        - { in: query, name: maxStep, schema: { type: integer, minimum: 0 }, description: "current step <= maxStep (0 = not contacted yet)" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProspectList" }
              example:
                data:
                  - id: pro_7a1c
                    workspaceId: ws_3c10
                    campaignId: cmp_4d92
                    campaignName: Q3 Founders
                    accountId: acc_8f21c0
                    state: active
                    fullName: Dana Lee
                    jobTitle: VP Engineering
                    company: Globex
                    linkedinUrl: https://www.linkedin.com/in/danalee
                    email: dana@globex.com
                    hasEmail: true
                    premium: false
                    blocklisted: false
                    currentStep: 2
                    totalSteps: 5
                    lastActivity: Connection accepted
                    lastActivityAt: "2026-06-23T18:40:00Z"
                    createdAt: "2026-06-10T08:00:00Z"
                    updatedAt: "2026-06-23T18:40:00Z"
                meta:
                  nextCursor: "25"
                  total: 1980
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /prospects/{id}:
    parameters:
      - $ref: "#/components/parameters/IdPath"
    get:
      tags: [Prospects]
      operationId: getProspect
      summary: Get a single prospect
      description: "Requires scope: `read`."
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Prospect" }
              example:
                data:
                  id: pro_7a1c
                  workspaceId: ws_3c10
                  campaignId: cmp_4d92
                  campaignName: Q3 Founders
                  state: active
                  fullName: Dana Lee
                  linkedinUrl: https://www.linkedin.com/in/danalee
                  currentStep: 2
                  totalSteps: 5
                  lastActivity: Connection accepted
                  lastActivityAt: "2026-06-23T18:40:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      tags: [Prospects]
      operationId: updateProspect
      summary: Update state / blocklist
      description: "Requires scope: `write`."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProspectUpdate" }
            example:
              state: paused
              blocklisted: true
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Prospect" }
              example:
                data:
                  id: pro_7a1c
                  state: paused
                  blocklisted: true
                  fullName: Dana Lee
                  currentStep: 2
                  totalSteps: 5
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /prospects/bulk:
    post:
      tags: [Prospects]
      operationId: bulkUpdateProspects
      summary: Bulk update (state / blocklist)
      description: |
        Applies the same state or blocklist flag to many prospects in one
        call. IDs not in the caller's workspace are silently skipped, the
        response counts only successful updates.

        Requires scope: `admin`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProspectBulkUpdate" }
            example:
              ids: [pro_7a1c, pro_8b2d, pro_9c3e]
              state: paused
      responses:
        "200":
          description: Applied
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      updated: { type: integer }
                      requested: { type: integer }
              example:
                data:
                  updated: 2
                  requested: 3
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /lead-values/{id}:
    patch:
      tags: [Custom variables]
      operationId: updateLeadValues
      summary: Edit a lead's personalization values
      description: |
        Change the custom variable VALUES on an existing lead, without
        re-pushing it. Send an empty string to delete a value.

        **Propagation.** By default the change also reaches prospects already
        materialized from this lead into running campaigns, with one guarantee:
        a variable is never rewritten for a prospect that has **already been
        sent a message using it**. So a correction reaches every message still
        ahead of that person and nothing behind them, and the record of what was
        actually sent stays truthful. Prospects that have completed or replied
        are skipped entirely.

        Set `propagate: false` to change the lead only.

        The response reports withheld and altered work, not just successes:
        `withheldTokens` names variables that were kept back for at least one
        prospect, and `truncatedTokens` names variables whose value was over the
        500 character cap and was therefore stored cut. The write still
        succeeds either way; both fields exist so a partial or altered result is
        never mistaken for a clean one.

        Requires scope: `write`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomValuesUpdate" }
            example:
              values: { recent_funding: Series C }
              propagate: true
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/CustomValueUpdateResult" }
        "400":
          description: "`no_usable_values`, every key was empty or collided with a built-in variable."
        "404": { $ref: "#/components/responses/NotFound" }
  /lead-values/bulk:
    post:
      tags: [Custom variables]
      operationId: bulkUpdateLeadValues
      summary: Edit values on many leads
      description: |
        Apply the same values to up to 1000 leads. Same propagation rules as
        `PATCH /lead-values/{id}`.

        **Partial success is normal and is reported, not hidden.** Each lead is
        updated atomically together with its own prospects, but the batch as a
        whole is resumable rather than one giant transaction: locking 1000 leads
        and their prospects for the duration would deadlock against the campaign
        runner, which writes the same prospect rows continuously.

        So a lead that can't be updated (deleted since you fetched it, or a
        transient write error) is counted in `failed` and named in `failedIds`,
        and the rest of the batch continues. Retry just those ids.

        If NOTHING was updated the response is `422 bulk_all_failed` rather than
        a 200, with the same counts in the body.

        `truncatedTokens` names any variable whose value exceeded the 500
        character cap and was stored cut. It is reported once for the request,
        not once per id: every id in the batch was given the same already
        shortened value.

        Requires scope: `write`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BulkCustomValuesUpdate" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/CustomValueUpdateResult" }
        "400": { description: "`missing_field` or `too_many_ids`." }
        "422":
          description: >
            `bulk_all_failed`, not a single id could be updated. The body still
            carries the counts and `failedIds` so you can see which.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/CustomValueUpdateResult" }
  /prospect-values/{id}:
    patch:
      tags: [Custom variables]
      operationId: updateProspectValues
      summary: Edit one prospect's personalization values
      description: |
        Change the values on ONE prospect in ONE campaign. Never touches the
        underlying lead or any other campaign, use this when a value is right in
        general but wrong for a particular campaign's angle.

        Unlike the lead endpoint this is **not** step-gated: the caller is
        addressing this specific record, so the change applies to every
        remaining message for it.

        `truncatedTokens` names any variable whose value exceeded the 500
        character cap and was therefore stored cut. The write still succeeded,
        so this is the only place the alteration is visible.

        Requires scope: `write`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomValuesUpdate" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/CustomValueUpdateResult" }
        "404": { $ref: "#/components/responses/NotFound" }
  /prospect-values/bulk:
    post:
      tags: [Custom variables]
      operationId: bulkUpdateProspectValues
      summary: Edit values on many prospects
      description: |
        Apply the same values to up to 1000 prospects. Affects those campaign
        records only, never the underlying leads.

        Partial success is reported the same way as the lead bulk endpoint:
        per-id failures land in `failed` / `failedIds` without aborting the
        batch, and a wholly-failed request returns `422 bulk_all_failed`.
        `truncatedTokens` likewise names any variable stored cut at the 500
        character cap, once for the request rather than once per id.

        Requires scope: `write`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BulkCustomValuesUpdate" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/CustomValueUpdateResult" }
        "400": { description: "`missing_field` or `too_many_ids`." }
        "422":
          description: >
            `bulk_all_failed`, not a single id could be updated. The body still
            carries the counts and `failedIds` so you can see which.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/CustomValueUpdateResult" }
  /sample-csv:
    get:
      tags: [Custom variables]
      operationId: getSampleLeadCsv
      summary: Download a lead import template
      description: |
        A ready-to-fill CSV for importing leads, generated for YOUR workspace:
        one column per registered custom variable, alongside the built-in lead
        fields. Use it instead of guessing header names, anything this endpoint
        emits is guaranteed to be recognized by the import path.

        Built-in columns populate lead fields. Every other column is IGNORED by
        default when the file is uploaded in the app, and becomes a `{{token}}`
        only if the person importing opts that column in. That default keeps an
        untrimmed export from filling the workspace with variables nobody wants.

        This differs from the JSON path on this API, where anything you place
        inside `custom` is always kept and registered automatically.

        **The CSV shape is not the API shape.** In the file, a variable is a
        top-level column. In a request body on this API it must be nested under
        `custom` (see the note on lead payloads above). The `apiRows` field of
        the JSON response is the already-converted form. One example row
        deliberately leaves a variable blank, which is what a lead missing that
        value looks like: it receives the step's fallback message rather than
        the personalized one.

        Returns `text/csv` as a file download by default. Pass
        `?format=json` to get the content inline instead, which is usually what
        you want from a script or an agent.

        Requires scope: `read`.
      parameters:
        - name: sink
          in: query
          required: false
          schema:
            type: string
            enum: [campaign, lead_list]
            default: campaign
          description: |
            Where the file will be uploaded. `lead_list` additionally includes
            the enrichment columns (`headline`, `location`) that a lead list
            stores but a campaign audience ignores. A column the destination
            does not read is dropped silently on import, so match this to where
            the file is actually going.
        - name: format
          in: query
          required: false
          schema:
            type: string
            enum: [json]
          description: Set to `json` to receive the CSV inline instead of as a download.
      responses:
        "200":
          description: OK
          content:
            text/csv:
              schema:
                type: string
              example: |
                linkedin_url,full_name,job_title,company,email,recent_funding
                https://www.linkedin.com/in/jane-okafor,Jane Okafor,VP of Engineering,Northwind Robotics,jane@northwind.example,"Series B, $40M"
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      filename:
                        type: string
                        example: outvelo-sample-campaign-leads.csv
                      sink:
                        type: string
                        example: campaign
                      csv:
                        type: string
                        description: The full file content.
                      apiRows:
                        type: array
                        description: |
                          The same example leads, already converted to the shape
                          THIS API takes. Use these rather than transposing the
                          CSV headers yourself: in the CSV a custom variable is
                          a top-level column, but on this API it must be nested
                          under `custom`. A loose top-level key passes request
                          validation and is then dropped, which returns 200 with
                          a correct `added` count and no personalization.
                        items:
                          type: object
                          example:
                            linkedinUrl: https://www.linkedin.com/in/jane-okafor
                            fullName: Jane Okafor
                            company: Northwind Robotics
                            custom:
                              recent_funding: Series B, $40M

  /custom-variables:
    get:
      tags: [Custom variables]
      operationId: listCustomVariables
      summary: List custom variables
      description: |
        Every custom personalization variable declared for your workspace.

        Call this before writing a sequence so you only reference tokens that
        actually resolve. `autoCreated: true` means the variable was discovered
        from an imported column and hasn't been reviewed by a human yet, those
        are the ones most likely to be missing a default value.

        Requires scope: `read`.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/CustomVariable" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Custom variables]
      operationId: createCustomVariable
      summary: Declare or update a custom variable
      description: |
        Upsert by token, so retries are safe and you don't need to check whether
        the variable already exists.

        The main reason to call this rather than relying on auto-discovery is
        `defaultValue`: without a default, any lead missing this value causes the
        whole message to fall back to the step's generic text.

        Tokens are normalized (`Recent Funding` becomes `recent_funding`). Tokens
        colliding with a built-in variable are rejected with `reserved_token`.

        Requires scope: `write`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomVariableCreate" }
            example:
              token: recent_funding
              label: Recent funding
              defaultValue: your recent round
      responses:
        "200":
          description: Created or updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/CustomVariable" }
        "400":
          description: |
            `reserved_token` (collides with a built-in) or
            `variable_limit_reached` (workspace cap hit).
        "401": { $ref: "#/components/responses/Unauthorized" }
  /custom-variables/{id}:
    patch:
      tags: [Custom variables]
      operationId: updateCustomVariable
      summary: Update a custom variable
      description: |
        Update the label, default value, or description.

        This endpoint does **not** change the token. To rename a variable use
        `POST /custom-variables/{id}/rename`, which is a separate call only
        because a rename is deliberate, not because it is expensive.

        Send an empty string to clear a default. Omitted fields are left alone.

        Requires scope: `write`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomVariableUpdate" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/CustomVariable" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Custom variables]
      operationId: deleteCustomVariable
      summary: Delete a custom variable
      description: |
        Removes the declaration. Values already stored on your leads are **not**
        deleted, so re-creating the same token restores full personalization.

        Refused with `409 variable_in_use` while an active (non-draft) campaign's
        sequence still references the token, because deleting it would quietly
        strip a phrase from messages already going out. Call
        `GET /custom-variables/{id}/usage` first to see exactly which campaigns
        those are.

        `?replaceWith=<token>` is the escape hatch from that refusal: every
        reference in every campaign sequence is repointed at the named variable
        and the original is then deleted, all in one transaction. Since the
        sequences still resolve, there is nothing left to refuse. The response
        reports `campaignsUpdated`.

        Requires scope: `write`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: replaceWith
          in: query
          required: false
          schema: { type: string }
          description: >
            Token of the variable to repoint every sequence at before deleting.
            Must already exist (a built-in counts); anything else is rejected
            with `replacement_unknown`, since pointing sequences at something
            that also resolves to nothing would just move the breakage.
          example: recent_round
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      deleted: { type: boolean }
                      campaignsUpdated:
                        type: integer
                        description: >
                          Campaign sequences rewritten to reference the
                          replacement. Always 0 when `replaceWith` was omitted.
              example:
                data: { deleted: true, campaignsUpdated: 2 }
        "400":
          description: "`replacement_unknown`, `replaceWith` does not name an existing variable."
        "409":
          description: "`variable_in_use`, an active campaign still references this token and no `replaceWith` was given."
        "404": { $ref: "#/components/responses/NotFound" }
  /custom-variables/{id}/rename:
    post:
      tags: [Custom variables]
      operationId: renameCustomVariable
      summary: Rename a custom variable
      description: |
        Change the variable's token.

        **This is safe and cheap.** Stored values and campaign message bodies
        reference the variable by its id, never by its name, so a rename updates
        a single registry row and leaves every value on every lead and every
        message body byte-for-byte identical. It costs the same on a workspace
        with four million leads as on one with four. There is no need to create
        a replacement variable and re-push data.

        A separate endpoint from `PATCH /custom-variables/{id}` only because the
        two want different confirmation: PATCH edits display-only fields, while
        the token is what humans read in a body and spell in a CSV header.

        The new token is normalized like any other (`Recent Funding` becomes
        `recent_funding`). Renaming to the name it already has succeeds quietly.

        Requires scope: `write`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomVariableRename" }
            example:
              token: recent_round
      responses:
        "200":
          description: Renamed
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/CustomVariableRenameResult" }
              example:
                data:
                  from: recent_funding
                  to: recent_round
        "400":
          description: |
            `reserved_token` (collides with a built-in), `invalid_token` (the
            name normalizes to nothing, e.g. `###`) or `invalid_body`.
        "409":
          description: "`token_taken`, another variable in this workspace already uses that name. Merging two variables is a different operation, so it is refused rather than guessed at."
        "404": { $ref: "#/components/responses/NotFound" }
  /custom-variables/{id}/usage:
    get:
      tags: [Custom variables]
      operationId: getCustomVariableUsage
      summary: What a delete would affect
      description: |
        Every campaign whose sequence references this variable, so you can see
        the blast radius of a delete before committing to it rather than
        inferring it from a `409` afterwards.

        Live and draft campaigns are reported separately because the
        consequences differ sharply: a **draft** can still be edited before it
        ever sends, while a **live** campaign would start dropping the phrase
        from messages already going out. A non-empty `live` list is exactly what
        makes `DELETE` return `409 variable_in_use`.

        Both reference forms are counted: the `{{var:<id>}}` the editor writes,
        and a bare `{{token}}` in a body that never passed through the editor.
        A token the author escaped (`\{{token}}`) is content, not a reference,
        and is not counted.

        Both lists are always arrays, never `null`.

        Requires scope: `read`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/CustomVariableUsage" }
              example:
                data:
                  token: recent_funding
                  live:
                    - { id: cmp_7d21, name: Q1 founder outreach, status: active }
                  drafts:
                    - { id: cmp_9a04, name: Q2 draft, status: draft }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /leads:
    post:
      tags: [Leads]
      operationId: createLead
      summary: Push a single lead
      description: |
        Add a single lead to one of your lead lists. `listId` is **required**:
        the lead is placed in that list (which must belong to your workspace),
        so a lead is never left without a list. For bulk uploads, use
        `POST /lead-lists/{id}/leads` instead.

        Requires scope: `write`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SingleLeadCreate" }
            example:
              fullName: Sam Rivera
              jobTitle: Head of Growth
              company: Initech
              linkedinUrl: https://www.linkedin.com/in/samrivera
              email: sam@initech.com
              listId: list_11aa
      responses:
        "200":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Lead" }
              example:
                data:
                  id: lead_2f8a
                  workspaceId: ws_3c10
                  leadListId: list_apiuploads
                  fullName: Sam Rivera
                  jobTitle: Head of Growth
                  company: Initech
                  linkedinUrl: https://www.linkedin.com/in/samrivera
                  email: sam@initech.com
                  hasEmail: true
                  createdAt: "2026-06-24T11:05:00Z"
                  updatedAt: "2026-06-24T11:05:00Z"
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /lead-lists:
    get:
      tags: [Lead lists]
      operationId: listLeadLists
      summary: List workspace lead lists
      description: "Requires scope: `read`."
      parameters:
        - { in: query, name: q, schema: { type: string }, description: "Search lead lists by name." }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/LeadList" }
              example:
                data:
                  - id: list_11aa
                    workspaceId: ws_3c10
                    createdBy: jane@acme.com
                    name: EMEA founders
                    source: csv
                    sourceMetadata: { description: "Imported from EMEA.csv" }
                    leadCount: 1240
                    campaignsUsing: 2
                    createdAt: "2026-05-02T09:00:00Z"
                    updatedAt: "2026-06-20T14:30:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Lead lists]
      operationId: createLeadList
      summary: Create a lead list
      description: "Requires scope: `write`."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LeadListCreate" }
            example:
              name: Q3 inbound demos
              description: Leads from the June webinar
              source: manual
      responses:
        "200":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/LeadList" }
              example:
                data:
                  id: list_55ff
                  workspaceId: ws_3c10
                  name: Q3 inbound demos
                  source: manual
                  sourceMetadata: { description: "Leads from the June webinar" }
                  leadCount: 0
                  campaignsUsing: 0
                  createdAt: "2026-06-24T11:10:00Z"
                  updatedAt: "2026-06-24T11:10:00Z"
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /lead-lists/{id}:
    parameters:
      - $ref: "#/components/parameters/IdPath"
    get:
      tags: [Lead lists]
      operationId: getLeadList
      summary: Get a single lead list
      description: "Requires scope: `read`."
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/LeadList" }
              example:
                data:
                  id: list_11aa
                  workspaceId: ws_3c10
                  name: EMEA founders
                  source: csv
                  leadCount: 1240
                  campaignsUsing: 2
                  createdAt: "2026-05-02T09:00:00Z"
                  updatedAt: "2026-06-20T14:30:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      tags: [Lead lists]
      operationId: updateLeadList
      summary: Rename / update
      description: "Requires scope: `write`."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LeadListUpdate" }
            example:
              name: EMEA founders (cleaned)
              description: Deduped June 2026
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/LeadList" }
              example:
                data:
                  id: list_11aa
                  workspaceId: ws_3c10
                  name: EMEA founders (cleaned)
                  source: csv
                  sourceMetadata: { description: "Deduped June 2026" }
                  leadCount: 1240
                  campaignsUsing: 2
                  updatedAt: "2026-06-24T11:12:00Z"
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Lead lists]
      operationId: deleteLeadList
      summary: Delete a lead list and its leads
      description: "Requires scope: `admin`."
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      deleted: { type: boolean }
              example:
                data: { deleted: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /lead-lists/{id}/leads:
    parameters:
      - $ref: "#/components/parameters/IdPath"
    get:
      tags: [Lead lists]
      operationId: listLeadListLeads
      summary: List leads in a list (paginated)
      description: "Requires scope: `read`."
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LeadCollection" }
              example:
                data:
                  - id: lead_2f8a
                    workspaceId: ws_3c10
                    leadListId: list_11aa
                    fullName: Sam Rivera
                    jobTitle: Head of Growth
                    company: Initech
                    linkedinUrl: https://www.linkedin.com/in/samrivera
                    email: sam@initech.com
                    hasEmail: true
                    createdAt: "2026-06-24T11:05:00Z"
                    updatedAt: "2026-06-24T11:05:00Z"
                meta:
                  nextCursor: "25"
                  total: 1240
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Lead lists]
      operationId: addLeadListLeads
      summary: Append leads to a list (bulk)
      description: |
        `source` selects how `data` is interpreted:

        - `csv`, `data.rows` is an array of row objects. Recognized keys
          (`linkedinUrl`, `fullName`, `jobTitle`, `company`, `email`, plus common
          aliases) map to built-in fields. Custom personalization values go in an
          explicit **`custom` object per row**, and unrecognized top-level keys
          are ignored. Keys inside `custom` become `{{token}}` in a sequence and
          are auto-registered for your workspace.
        - `linkedin_urls`, `data.urls` is an array of LinkedIn profile URLs.

        Max 5000 leads per call.

        A value inside `custom` longer than 500 characters is stored SHORTENED.
        The add still succeeds, so `truncated` (how many values were cut) and
        `truncatedTokens` (which variables, capped sample) are the only place
        that alteration shows. This matters most when a variable carries a whole
        generated message rather than a short phrase, because the cut lands
        mid-sentence in what the prospect receives.

        Requires scope: `write`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LeadListAddLeads" }
            examples:
              csv:
                summary: CSV rows
                value:
                  source: csv
                  data:
                    rows:
                      - { linkedinUrl: "https://www.linkedin.com/in/danalee", fullName: Dana Lee, jobTitle: VP Engineering, company: Globex, email: dana@globex.com }
              linkedinUrls:
                summary: LinkedIn URLs
                value:
                  source: linkedin_urls
                  data:
                    urls: ["https://www.linkedin.com/in/danalee", "https://www.linkedin.com/in/samrivera"]
      responses:
        "200":
          description: Added
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/AddLeadsResult" }
              example:
                data: { added: 9, updated: 1, skipped: 2, failed: 0, total: 412 }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /lead-lists/{id}/leads/{leadId}:
    parameters:
      - $ref: "#/components/parameters/IdPath"
      - name: leadId
        in: path
        required: true
        schema: { type: string }
    delete:
      tags: [Lead lists]
      operationId: removeLeadFromList
      summary: Remove a single lead from a list
      description: "Requires scope: `write`."
      responses:
        "200":
          description: Removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      removed: { type: boolean }
              example:
                data: { removed: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /campaigns:
    get:
      tags: [Campaigns]
      operationId: listCampaigns
      summary: List campaigns
      description: "Requires scope: `read`."
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CampaignList" }
              example:
                data:
                  - id: cmp_4d92
                    workspaceId: ws_3c10
                    name: Q3 Founders
                    status: active
                    source: csv
                    audienceSource: csv
                    stats: { requests_sent: 420, replied_count: 31 }
                    createdAt: "2026-06-01T09:00:00Z"
                    updatedAt: "2026-06-24T08:00:00Z"
                meta:
                  nextCursor: cmp_4d92
                  total: 7
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Campaigns]
      operationId: createCampaign
      summary: Create a campaign
      description: |
        Creates a campaign with its full config (sequence tree, schedule,
        accounts, limits). A `sequence` must be a JSON array of steps (max 200
        steps, 25 nesting levels). Setting `status: active` runs activation
        validation first.

        Requires scope: `write`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CampaignCreate" }
            example:
              name: Q3 Founders
              status: draft
              audienceSource: linkedin_urls
              audienceData:
                urls: ["https://www.linkedin.com/in/danalee"]
              accountIds: ["acc_8f21c0"]
              sequence:
                - { type: connection_request, delayHours: 0 }
                - { type: follow_up, delayHours: 48, body: "Hi {{firstName}}, thanks for connecting!" }
      responses:
        "200":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CampaignSingle" }
              example:
                data:
                  id: cmp_4d92
                  workspaceId: ws_3c10
                  name: Q3 Founders
                  status: draft
                  audienceSource: linkedin_urls
                  accountIds: ["acc_8f21c0"]
                  createdAt: "2026-06-24T11:20:00Z"
                  updatedAt: "2026-06-24T11:20:00Z"
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /campaigns/{id}:
    parameters:
      - $ref: "#/components/parameters/IdPath"
    get:
      tags: [Campaigns]
      operationId: getCampaign
      summary: Get one campaign
      description: "Requires scope: `read`."
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CampaignSingle" }
              example:
                data:
                  id: cmp_4d92
                  workspaceId: ws_3c10
                  name: Q3 Founders
                  status: active
                  source: csv
                  audienceSource: csv
                  stats: { requests_sent: 420, replied_count: 31 }
                  createdAt: "2026-06-01T09:00:00Z"
                  updatedAt: "2026-06-24T08:00:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      tags: [Campaigns]
      operationId: updateCampaign
      summary: Update name / status / config
      description: |
        Update any subset of fields. Structural edits (sequence, audience,
        accounts) on an active campaign are rejected with `409` unless the same
        request also pauses it (`status: paused` or `draft`).

        Requires scope: `write`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CampaignUpdate" }
            example:
              name: Q3 Founders (v2)
              status: paused
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CampaignSingle" }
              example:
                data:
                  id: cmp_4d92
                  name: Q3 Founders (v2)
                  status: paused
                  updatedAt: "2026-06-24T11:25:00Z"
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Campaigns]
      operationId: deleteCampaign
      summary: Delete a campaign and its prospects
      description: "Requires scope: `admin`."
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      deleted: { type: boolean }
              example:
                data: { deleted: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /campaigns/{id}/leads:
    parameters:
      - $ref: "#/components/parameters/IdPath"
    post:
      tags: [Campaigns]
      operationId: addCampaignLeads
      summary: Append leads to a campaign
      description: |
        Appends prospects to an existing campaign. `audienceSource` selects how
        `audienceData` is interpreted:

        - `csv`, `audienceData.rows` is an array of row objects. Recognized keys
          (`linkedinUrl`, `fullName`, `jobTitle`, `company`, `email`, plus common
          aliases) map to built-in fields. Custom personalization values go in an
          explicit **`custom` object per row**; unrecognized top-level keys are
          ignored. (`linkedin_urls` takes bare URL strings and therefore cannot
          carry custom values, use `csv` rows if you need personalization.)
        - `linkedin_urls`, `audienceData.urls` is an array of LinkedIn profile URLs.

        Returns counts: `added` (new), `updated` (an existing prospect refreshed),
        `skipped` (duplicate or blocklisted), `failed` (invalid URL).
        Max 5000 leads per call.

        A custom value longer than 500 characters is stored SHORTENED. The add
        still succeeds, so `truncated` (how many values were cut) and
        `truncatedTokens` (which variables, capped sample) are the only place
        that alteration shows. Check them whenever a variable holds a written
        message rather than a short phrase.

        Requires scope: `write`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CampaignAddLeads" }
            examples:
              csv:
                summary: CSV rows
                value:
                  audienceSource: csv
                  audienceData:
                    rows:
                      - { linkedinUrl: "https://www.linkedin.com/in/danalee", fullName: Dana Lee, company: Globex }
              linkedinUrls:
                summary: LinkedIn URLs
                value:
                  audienceSource: linkedin_urls
                  audienceData:
                    urls: ["https://www.linkedin.com/in/danalee"]
      responses:
        "200":
          description: Added
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/AddLeadsResult" }
              example:
                data: { added: 12, updated: 2, skipped: 3, failed: 0 }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /campaigns/{id}/prospects:
    parameters:
      - $ref: "#/components/parameters/IdPath"
      - $ref: "#/components/parameters/Cursor"
      - $ref: "#/components/parameters/Limit"
    get:
      tags: [Prospects]
      operationId: listCampaignProspects
      summary: List a campaign's prospects (paginated)
      description: |
        Same filters as `GET /prospects`, scoped to this campaign.
        `meta.total` is the total matching the filters.

        Requires scope: `read`.
      parameters:
        - { in: query, name: state, schema: { type: string, enum: [active, paused, completed, bounced, replied, suppressed] }, description: "suppressed = skipped because another campaign already owns the lead" }
        - { in: query, name: blocklisted, schema: { type: string, enum: ["true", "false"] } }
        - { in: query, name: replied, schema: { type: string, enum: ["true", "false"] }, description: "true = state replied, false = state is anything else" }
        - { in: query, name: minStep, schema: { type: integer, minimum: 0 }, description: "current step >= minStep (1 = contacted)" }
        - { in: query, name: maxStep, schema: { type: integer, minimum: 0 }, description: "current step <= maxStep (0 = not contacted yet)" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProspectList" }
              example:
                data:
                  - id: pro_7a1c
                    campaignId: cmp_4d92
                    fullName: Dana Lee
                    linkedinUrl: https://www.linkedin.com/in/danalee
                    state: active
                    currentStep: 2
                    totalSteps: 5
                    lastActivity: Connection accepted
                    lastActivityAt: "2026-06-23T18:40:00Z"
                meta:
                  nextCursor: pro_7a1c
                  total: 500
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /campaigns/{id}/funnel:
    parameters:
      - $ref: "#/components/parameters/IdPath"
    get:
      tags: [Campaigns]
      operationId: getCampaignFunnel
      summary: Per-step funnel counts
      description: "Requires scope: `read`."
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/CampaignFunnel" }
              example:
                data:
                  perStep: [500, 412, 138, 96, 31]
                  perNode: { connection_request: 500, follow_up: 412 }
                  perEnd: { completed: 96, replied: 31 }
                  replied: 31
                  bounced: 8
                  paused: 4
                  completed: 96
                  total: 500
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /campaigns/{id}/activity:
    parameters:
      - $ref: "#/components/parameters/IdPath"
    get:
      tags: [Campaigns]
      operationId: getCampaignActivity
      summary: Per-action activity timeline
      description: |
        Returns the campaign's per-action audit trail (newest first), what the
        runner actually did per lead.

        Requires scope: `read`.
      parameters:
        - { in: query, name: prospectId, schema: { type: string }, description: "Filter to one prospect." }
        - { in: query, name: status, schema: { type: string, enum: [dispatched, branch, accepted, failed, bounced, paused, withdrawn, completed, suppressed] } }
        - { in: query, name: limit, schema: { type: integer }, description: "Max rows to return." }
        - { in: query, name: before, schema: { type: string, format: date-time }, description: "RFC3339 cursor, return rows created before this time." }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/CampaignActivity" }
              example:
                data:
                  - id: act_1
                    workspaceId: ws_3c10
                    campaignId: cmp_4d92
                    prospectId: pro_7a1c
                    accountId: acc_8f21c0
                    linkedinUrl: https://www.linkedin.com/in/danalee
                    stepId: step_2
                    action: connection_request
                    status: dispatched
                    detail: ""
                    createdAt: "2026-06-23T18:40:00Z"
                  - id: act_2
                    workspaceId: ws_3c10
                    campaignId: cmp_4d92
                    prospectId: pro_7a1c
                    accountId: acc_8f21c0
                    action: ""
                    status: accepted
                    createdAt: "2026-06-23T19:05:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /conversations:
    get:
      tags: [Conversations]
      operationId: listConversations
      summary: List inbox conversations
      description: "Requires scope: `read`."
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - { in: query, name: status, schema: { type: string, enum: [unread, replied, paused, archived] } }
        - { in: query, name: source, schema: { type: string, enum: [linkedin, sales_navigator] } }
        - { in: query, name: accountIds, schema: { type: string }, description: "Comma-separated account ids." }
        - { in: query, name: campaignIds, schema: { type: string }, description: "Comma-separated campaign ids." }
        - { in: query, name: leadFilter, schema: { type: string, enum: [leads, non_leads] } }
        - { in: query, name: tags, schema: { type: string }, description: "Comma-separated tag strings." }
        - { in: query, name: q, schema: { type: string } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConversationList" }
              example:
                data:
                  - id: conv_aa11
                    workspaceId: ws_3c10
                    accountId: acc_8f21c0
                    campaignId: cmp_4d92
                    participantName: Dana Lee
                    participantHeadline: VP Engineering at Globex
                    participantCompany: Globex
                    participantLinkedinUrl: https://www.linkedin.com/in/danalee
                    status: replied
                    tags: ["qualified"]
                    unreadCount: 0
                    isInmail: false
                    lastMessageAt: "2026-06-24T09:30:00Z"
                    lastMessageSnippet: "You: sounds good, let's chat Thursday"
                    source: linkedin
                    currentStep: 3
                    totalSteps: 5
                    createdAt: "2026-06-20T10:00:00Z"
                    updatedAt: "2026-06-24T09:30:00Z"
                meta:
                  nextCursor: conv_aa11
                  total: 84
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /conversations/{id}:
    parameters:
      - $ref: "#/components/parameters/IdPath"
    get:
      tags: [Conversations]
      operationId: getConversation
      summary: Get a conversation and its messages
      description: "Requires scope: `read`."
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/ConversationDetail" }
              example:
                data:
                  conversation:
                    id: conv_aa11
                    workspaceId: ws_3c10
                    accountId: acc_8f21c0
                    participantName: Dana Lee
                    status: replied
                    source: linkedin
                    currentStep: 3
                    totalSteps: 5
                  messages:
                    - id: msg_1
                      conversationId: conv_aa11
                      direction: out
                      body: "Hi Dana, thanks for connecting!"
                      sentAt: "2026-06-22T14:00:00Z"
                      createdAt: "2026-06-22T14:00:00Z"
                    - id: msg_2
                      conversationId: conv_aa11
                      direction: in
                      body: "Happy to chat, Thursday works."
                      sentAt: "2026-06-24T09:30:00Z"
                      createdAt: "2026-06-24T09:30:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      tags: [Conversations]
      operationId: updateConversation
      summary: Triage a conversation (status / tags)
      description: |
        Change a conversation's status (archive, mark replied/unread/paused)
        and/or set its tag list.

        Requires scope: `write`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ConversationUpdate" }
            example:
              status: archived
              tags: ["qualified", "follow-up-q4"]
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Conversation" }
              example:
                data:
                  id: conv_aa11
                  status: archived
                  tags: ["qualified", "follow-up-q4"]
                  updatedAt: "2026-06-24T11:30:00Z"
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /conversations/{id}/messages:
    parameters:
      - $ref: "#/components/parameters/IdPath"
    post:
      tags: [Conversations]
      operationId: sendConversationMessage
      summary: Send a message to a conversation
      description: "Requires scope: `write`."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SendMessage" }
            example:
              body: "Great, I'll send a calendar invite for Thursday 2pm ET."
      responses:
        "200":
          description: Sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Message" }
              example:
                data:
                  id: msg_3
                  conversationId: conv_aa11
                  providerMessageId: prov_9912
                  direction: out
                  body: "Great, I'll send a calendar invite for Thursday 2pm ET."
                  sentAt: "2026-06-24T11:32:00Z"
                  createdAt: "2026-06-24T11:32:00Z"
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "424":
          description: Account not ready (disconnected, reconnect it from the Accounts page)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example:
                error: { code: account_not_ready, message: "sending account isn't connected, reconnect it from the Accounts page" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /accounts:
    get:
      tags: [Accounts]
      operationId: listAccounts
      summary: List workspace accounts
      description: "Requires scope: `read`."
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountList" }
              example:
                data:
                  - id: acc_8f21c0
                    workspaceId: ws_3c10
                    addedByEmail: jane@acme.com
                    provider: linkedin
                    name: Jane Doe
                    linkedinEmail: jane@acme.com
                    status: OK
                    enabled: true
                    pictureUrl: https://media.licdn.com/.../jane.jpg
                    createdAt: "2026-04-01T09:00:00Z"
                    updatedAt: "2026-06-24T08:00:00Z"
                meta:
                  nextCursor: acc_8f21c0
                  total: 3
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /accounts/{id}:
    parameters:
      - $ref: "#/components/parameters/IdPath"
    get:
      tags: [Accounts]
      operationId: getAccount
      summary: Get a single account
      description: "Requires scope: `read`."
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Account" }
              example:
                data:
                  id: acc_8f21c0
                  workspaceId: ws_3c10
                  provider: linkedin
                  name: Jane Doe
                  linkedinEmail: jane@acme.com
                  status: OK
                  enabled: true
                  createdAt: "2026-04-01T09:00:00Z"
                  updatedAt: "2026-06-24T08:00:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /accounts/{id}/health:
    parameters:
      - $ref: "#/components/parameters/IdPath"
    get:
      tags: [Accounts]
      operationId: getAccountHealth
      summary: Can this account send right now
      description: |
        Combined readout used to decide whether dispatching to this account is
        worthwhile right now: `schedule` says whether we're inside its working
        window, `usage` shows remaining per-action quota for today, `platform`
        says what LinkedIn tolerates for this account's tier, and `account`
        carries the status/enabled flags.

        Daily quota alone is not the answer. An account with headroom today can
        still be frozen by the rolling weekly invite ceiling, check
        `usage[connection_invite].weekly.exhausted` when that block is present.

        Requires scope: `read`.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/AccountHealth" }
              example:
                data:
                  account:
                    id: acc_8f21c0
                    name: Jane Doe
                    status: OK
                    enabled: true
                  schedule:
                    allowedNow: true
                    reason: Active
                    localTime: "14:42"
                    localDate: "2026-06-24"
                    localWeekday: Wednesday
                  usage:
                    - actionType: connection_invite
                      used: 14
                      limit: 22
                      range: { min: 20, max: 25 }
                      weekly: { used: 118, cap: 200, remaining: 82, exhausted: false, dailyCeiling: 28 }
                  platform:
                    tier: sales_navigator
                    tierLabel: Sales Navigator
                    tierAsOf: "2026-07-31T09:14:03Z"
                    noteCharLimit: 300
                    guidance:
                      - action: connection_invite
                        safeDaily: 28
                        safeWeekly: 200
                        note: "LinkedIn counts invites per week, not per day."
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /webhooks:
    get:
      tags: [Webhooks]
      operationId: listWebhooks
      summary: List webhook subscriptions
      description: "Requires scope: `read`."
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Webhook" }
              example:
                data:
                  - id: wh_5b2a91
                    workspaceId: ws_3c10
                    name: Push to Outvelo - P1
                    url: https://example.com/hooks/outvelo
                    events: ["lead.replied", "connection.accepted"]
                    active: true
                    lastFiredAt: "2026-06-24T09:30:00Z"
                    createdBy: jane@acme.com
                    createdAt: "2026-06-01T09:14:22Z"
                    updatedAt: "2026-06-24T09:30:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Webhooks]
      operationId: createWebhook
      summary: Register a webhook
      description: |
        Receiver URLs must be HTTPS and publicly resolvable. The signing
        `secret` is returned ONCE in this response and never again.

        Requires scope: `write`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookCreate" }
            example:
              url: https://example.com/hooks/outvelo
              events: ["lead.replied", "connection.accepted"]
              active: true
      responses:
        "200":
          description: Created (response includes the `secret` once)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/WebhookWithSecret" }
              example:
                data:
                  id: wh_5b2a91
                  workspaceId: ws_3c10
                  url: https://example.com/hooks/outvelo
                  events: ["lead.replied", "connection.accepted"]
                  active: true
                  createdBy: jane@acme.com
                  createdAt: "2026-06-24T11:35:00Z"
                  updatedAt: "2026-06-24T11:35:00Z"
                  secret: whsec_4f9a1c2b8e3d7a60
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /webhooks/{id}:
    parameters:
      - $ref: "#/components/parameters/IdPath"
    get:
      tags: [Webhooks]
      operationId: getWebhook
      summary: Get a webhook subscription
      description: |
        Returns the subscription. The signing `secret` is never included here,
        it is shown only at creation or when rotated.

        Requires scope: `read`.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Webhook" }
              example:
                data:
                  id: wh_5b2a91
                  workspaceId: ws_3c10
                  name: Push to Outvelo - P1
                  url: https://example.com/hooks/outvelo
                  events: ["lead.replied"]
                  active: true
                  createdAt: "2026-06-01T09:14:22Z"
                  updatedAt: "2026-06-24T09:30:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      tags: [Webhooks]
      operationId: updateWebhook
      summary: Update a webhook subscription
      description: |
        Change the `url`, `events`, or `active` flag (`active: false` pauses
        deliveries).

        Requires scope: `write`.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookUpdate" }
            example:
              url: https://example.com/hooks/outvelo-v2
              events: ["lead.replied", "lead.bounced"]
              active: true
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Webhook" }
              example:
                data:
                  id: wh_5b2a91
                  url: https://example.com/hooks/outvelo-v2
                  events: ["lead.replied", "lead.bounced"]
                  active: true
                  updatedAt: "2026-06-24T11:40:00Z"
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Webhooks]
      operationId: deleteWebhook
      summary: Delete a webhook subscription
      description: "Requires scope: `write`."
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      deleted: { type: boolean }
              example:
                data: { deleted: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: "ak_…"
  parameters:
    IdPath:
      name: id
      in: path
      required: true
      schema: { type: string }
    Cursor:
      name: cursor
      in: query
      schema: { type: string }
      description: Opaque pagination cursor returned in `meta.nextCursor`.
    Limit:
      name: limit
      in: query
      schema: { type: integer, default: 25, minimum: 1, maximum: 100 }
  responses:
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error: { code: not_found, message: "campaign not found" }
    InvalidRequest:
      description: Invalid request (bad/missing fields)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error: { code: invalid_body, message: "name is required" }
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error: { code: unauthorized, message: "missing or invalid API key" }
    Forbidden:
      description: API key lacks the required scope
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error: { code: insufficient_scope, message: "this endpoint requires the 'write' scope" }
    Conflict:
      description: Request conflicts with the resource's current state
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error: { code: campaign_active, message: "pause the campaign before editing its sequence, audience, or sending accounts" }
    RateLimited:
      description: Too many requests, back off and retry
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error: { code: rate_limited, message: "rate limit exceeded, retry after 1s" }
  schemas:
    Meta:
      type: object
      description: Pagination metadata, present on collection responses.
      properties:
        nextCursor:
          type: string
          description: Pass back as `cursor` to fetch the next page. Empty/absent when there are no more rows.
        total:
          type: integer
          description: Total number of rows matching the query (before pagination).
    Error:
      type: object
      description: Error envelope. Mutually exclusive with `data`.
      properties:
        error:
          type: object
          properties:
            code: { type: string, description: "Short machine-readable code (e.g. not_found, invalid_body)." }
            message: { type: string, description: "Human-readable explanation." }
            requestId: { type: string, description: "Correlation id (5xx only), quote it to support." }

    Campaign:
      type: object
      properties:
        id: { type: string, example: cmp_4d92 }
        workspaceId: { type: string }
        createdBy: { type: string, description: "User email that created the campaign." }
        name: { type: string }
        status: { type: string, enum: [draft, active, paused, completed] }
        source: { type: string, description: "Display label derived from audienceSource (CSV, LinkedIn Search, Lead List, ...)." }
        audienceSource: { type: string, enum: [csv, linkedin_search, linkedin_urls, lead_list] }
        audienceData: { type: object, description: "Audience config; shape depends on audienceSource." }
        sequence: { type: array, items: { type: object }, description: "Ordered array of sequence steps." }
        dailyLimits: { type: object, description: "Per-action daily caps." }
        accountIds: { type: array, items: { type: string }, description: "Connected account ids that send for this campaign." }
        schedule: { type: object, description: "Campaign-level sending window." }
        stats: { type: object, description: "Running counters (requests_sent, requests_accepted, replied_count, profiles_touched, total_prospects)." }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    CampaignSingle:
      type: object
      properties:
        data: { $ref: "#/components/schemas/Campaign" }
    CampaignList:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Campaign" }
        meta: { $ref: "#/components/schemas/Meta" }
    CampaignCreate:
      type: object
      required: [name]
      properties:
        name: { type: string }
        status: { type: string, enum: [draft, active], default: draft }
        audienceSource: { type: string, enum: [csv, linkedin_urls] }
        audienceData: { type: object }
        sequence: { type: array, items: { type: object }, description: "JSON array of steps (max 200 steps, 25 nesting levels)." }
        dailyLimits: { type: object }
        accountIds: { type: array, items: { type: string } }
        schedule: { type: object }
    CampaignUpdate:
      type: object
      description: All fields optional, only what's sent is applied.
      properties:
        name: { type: string }
        status: { type: string, enum: [draft, active, paused, completed] }
        audienceSource: { type: string, enum: [csv, linkedin_urls] }
        audienceData: { type: object }
        sequence: { type: array, items: { type: object } }
        dailyLimits: { type: object }
        accountIds: { type: array, items: { type: string } }
        schedule: { type: object }
    CampaignAddLeads:
      type: object
      required: [audienceSource, audienceData]
      properties:
        audienceSource: { type: string, enum: [csv, linkedin_urls] }
        audienceData: { type: object }
    CampaignFunnel:
      type: object
      properties:
        perStep: { type: array, items: { type: integer }, description: "Count of prospects that reached each step index." }
        perNode: { type: object, additionalProperties: { type: integer }, description: "Counts keyed by sequence node id/type." }
        perEnd: { type: object, additionalProperties: { type: integer }, description: "Counts keyed by terminal outcome." }
        replied: { type: integer }
        bounced: { type: integer }
        paused: { type: integer }
        completed: { type: integer }
        total: { type: integer }
    CampaignActivity:
      type: object
      properties:
        id: { type: string }
        workspaceId: { type: string }
        campaignId: { type: string }
        prospectId: { type: string }
        accountId: { type: string }
        linkedinUrl: { type: string }
        stepId: { type: string }
        action: { type: string, description: "Sequence step type; empty for status-only events." }
        status: { type: string, enum: [dispatched, branch, accepted, failed, bounced, paused, withdrawn, completed, suppressed] }
        detail: { type: string, description: "Free-text context (branch decision, error, reason)." }
        createdAt: { type: string, format: date-time }

    Prospect:
      type: object
      properties:
        addedVia:
          type: object
          description: How this person got into Outvelo. channel is unknown for rows added before this was tracked.
          properties:
            importId: { type: string }
            channel: { type: string, enum: [app, api, mcp, integration, unknown] }
            method: { type: string, enum: [csv, linkedin_urls, lead_list, linkedin_search, manual] }
            actorName: { type: string, example: "API key 'Claude'" }
            createdAt: { type: string, format: date-time }
        id: { type: string, example: pro_7a1c }
        workspaceId: { type: string }
        campaignId: { type: string }
        campaignName: { type: string }
        accountId: { type: string }
        state: { type: string, enum: [active, paused, completed, bounced, replied, suppressed] }
        fullName: { type: string }
        jobTitle: { type: string }
        company: { type: string }
        linkedinUrl: { type: string }
        email: { type: string }
        pictureUrl: { type: string }
        headline: { type: string }
        location: { type: string }
        premium: { type: boolean }
        hasEmail: { type: boolean }
        blocklisted: { type: boolean }
        currentStep: { type: integer }
        totalSteps: { type: integer }
        inviteSentAt: { type: string, format: date-time }
        lastActivity: { type: string }
        lastActivityAt: { type: string, format: date-time }
        metadata:
          type: object
          nullable: true
          description: >
            Free-form storage. Custom personalization values live under the
            reserved `custom` key, keyed by variable token. Other keys are
            internal sequence/runner bookkeeping and may change without notice,
            read `metadata.custom` and ignore the rest.
          example:
            custom:
              recent_funding: Series B
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    ProspectList:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Prospect" }
        meta: { $ref: "#/components/schemas/Meta" }
    ProspectUpdate:
      type: object
      properties:
        state: { type: string, enum: [active, paused, completed, bounced, replied] }
        blocklisted: { type: boolean }
    ProspectBulkUpdate:
      type: object
      required: [ids]
      properties:
        ids: { type: array, items: { type: string } }
        state: { type: string, enum: [active, paused, completed, bounced, replied] }
        blocklisted: { type: boolean }

    Lead:
      type: object
      properties:
        addedVia:
          type: object
          description: How this person got into Outvelo. channel is unknown for rows added before this was tracked.
          properties:
            importId: { type: string }
            channel: { type: string, enum: [app, api, mcp, integration, unknown] }
            method: { type: string, enum: [csv, linkedin_urls, lead_list, linkedin_search, manual] }
            actorName: { type: string, example: "API key 'Claude'" }
            createdAt: { type: string, format: date-time }
        id: { type: string, example: lead_2f8a }
        workspaceId: { type: string }
        leadListId: { type: string }
        fullName: { type: string }
        jobTitle: { type: string }
        company: { type: string }
        linkedinUrl: { type: string }
        email: { type: string }
        pictureUrl: { type: string }
        headline: { type: string }
        location: { type: string }
        premium: { type: boolean }
        hasEmail: { type: boolean }
        metadata:
          type: object
          nullable: true
          description: >
            Free-form storage. Custom personalization values live under the
            reserved `custom` key, keyed by variable token. Other keys are
            internal (provider ids, sequence bookkeeping) and may change without
            notice, read `metadata.custom` and ignore the rest.
          example:
            custom:
              recent_funding: Series B
              industry: fintech
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    AddLeadsResult:
      type: object
      description: >
        Counts from a bulk add. `added` = new records inserted; `updated` = an
        existing record matched by LinkedIn URL and refreshed; `skipped` =
        duplicates or blocklisted contacts; `failed` = missing/invalid LinkedIn
        URL. `total` (lead-list add only) is the list's resulting lead count.

        `truncated` / `truncatedTokens` sit outside that breakdown deliberately:
        a truncated value is not a fifth outcome, it is a successful add whose
        data was altered on the way in. The buckets answer "did the record
        land"; these two answer "is what landed what you sent".
      properties:
        added: { type: integer }
        updated: { type: integer }
        skipped: { type: integer }
        failed: { type: integer }
        total: { type: integer }
        truncated:
          type: integer
          description: >
            How many personalization values were stored SHORTENED because they
            exceeded the 500 character cap, counted once per lead per variable.
            The import still succeeded; nothing was rejected. Present because a
            variable is often a whole written message (a step body of just
            `{{opener}}`), and a silent cut means the prospect receives half a
            sentence.
          example: 12
        truncatedTokens:
          type: array
          items: { type: string }
          description: >
            A small capped sample of the distinct variable names involved, so the
            report is actionable ("shorten `opener`") without a 5000-lead import
            answering with 5000 entries. `truncated` carries the true scale.
            Names, not storage keys.
          example: [opener]

    LeadCollection:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Lead" }
        meta: { $ref: "#/components/schemas/Meta" }
    CustomValuesUpdate:
      type: object
      required: [values]
      properties:
        values:
          type: object
          additionalProperties: { type: string }
          description: >
            Variable token -> new value. An empty string DELETES that variable's
            value rather than storing a blank (a blank would otherwise count as
            coverage and suppress the fallback message). Keys colliding with a
            built-in variable are ignored.
          example: { recent_funding: Series C }
        propagate:
          type: boolean
          default: true
          description: >
            Lead endpoints only. When true (the default), also update prospects
            already materialized from this lead, except any that have already
            been sent a message using the variable.
    BulkCustomValuesUpdate:
      type: object
      required: [ids, values]
      properties:
        ids:
          type: array
          maxItems: 1000
          items: { type: string }
        values:
          type: object
          additionalProperties: { type: string }
        propagate: { type: boolean, default: true }
    CustomValueUpdateResult:
      type: object
      description: >
        Reports withheld and ALTERED work as well as successes. Propagation is
        per-token and per-prospect, so "saved" alone would hide that some
        prospects kept their old value; and a value over the 500 character cap
        is stored shortened, so "saved" alone would also hide that what is now on
        the record is not what you sent.
      properties:
        leadUpdated: { type: boolean }
        leadsUpdated: { type: integer, description: Bulk counterpart of leadUpdated. }
        prospectsUpdated: { type: integer }
        prospectsSkipped:
          type: integer
          description: >
            Prospects left untouched: already completed or replied, or every
            requested variable had already been sent to them.
        withheldTokens:
          type: array
          items: { type: string }
          description: >
            Variables NOT propagated to at least one prospect because a message
            using them had already gone out.
        failed:
          type: integer
          description: >
            Ids in a bulk request that couldn't be applied, whether the record
            doesn't exist or the write itself errored.
        failedIds:
          type: array
          items: { type: string }
          description: >
            The first few failing ids (capped server-side; `failed` carries the
            true total), so a retry can target exactly what didn't land.
        truncatedTokens:
          type: array
          items: { type: string }
          description: >
            Variables whose value was longer than 500 characters and was
            therefore STORED CUT. The write still succeeded, so this is a report
            and never a refusal, but the value now on the record is not the value
            you sent. Named because a variable is often a whole written message
            (a step body of just `{{opener}}`), where a silent cut means half a
            sentence is delivered to the prospect. Shorten the text and send it
            again if that matters. Names, not storage keys. Capped like
            `failedIds`.
    CustomVariable:
      type: object
      properties:
        id: { type: string, example: cvar_9f21 }
        workspaceId: { type: string }
        token:
          type: string
          description: The name used between braces, without them.
          example: recent_funding
        label: { type: string, example: Recent funding }
        defaultValue:
          type: string
          description: >
            Substituted when a lead has no value of its own. Empty means missing
            values fall through to the step's fallback message instead.
          example: your recent round
        description: { type: string }
        autoCreated:
          type: boolean
          description: >
            True when the variable was discovered from an imported column rather
            than declared deliberately, i.e. nobody has reviewed it yet.
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    CustomVariableCreate:
      type: object
      required: [token]
      properties:
        token:
          type: string
          description: >
            Variable name WITHOUT braces. Normalized on write, so
            `Recent Funding` is accepted and stored as `recent_funding`.
          example: recent_funding
        label: { type: string }
        defaultValue: { type: string }
        description: { type: string }
    CustomVariableUpdate:
      type: object
      properties:
        label: { type: string }
        defaultValue:
          type: string
          description: Send an empty string to clear the default.
        description: { type: string }
    CustomVariableRename:
      type: object
      required: [token]
      properties:
        token:
          type: string
          description: >
            The new name, WITHOUT braces. Normalized on write, exactly as on
            create. Passing the name the variable already has is a no-op that
            still returns 200.
          example: recent_round
    CustomVariableRenameResult:
      type: object
      description: >
        The old and new name, and nothing else. Values and message bodies
        reference a variable by its id, so a rename rewrites nothing beyond the
        registry row and there is nothing further to report.
      properties:
        from: { type: string, example: recent_funding }
        to: { type: string, example: recent_round }
    CampaignRef:
      type: object
      description: The minimum a warning needs, something to name and something to link to.
      properties:
        id: { type: string, example: cmp_7d21 }
        name: { type: string, example: Q1 founder outreach }
        status: { type: string, example: active }
    CustomVariableUsage:
      type: object
      properties:
        token: { type: string, example: recent_funding }
        live:
          type: array
          description: >
            Campaigns that are not drafts. A non-empty list here is what blocks
            a plain DELETE with `409 variable_in_use`; delete with
            `?replaceWith=<token>` to proceed anyway.
          items: { $ref: "#/components/schemas/CampaignRef" }
        drafts:
          type: array
          description: >
            Draft campaigns. These do not block a delete, but their bodies would
            silently lose the token, so they are still worth reviewing.
          items: { $ref: "#/components/schemas/CampaignRef" }
    SingleLeadCreate:
      type: object
      required: [linkedinUrl, listId]
      properties:
        fullName: { type: string }
        jobTitle: { type: string }
        company: { type: string }
        linkedinUrl: { type: string, format: uri }
        email: { type: string, format: email }
        listId:
          type: string
          description: Required. The lead list to add this lead to; must belong to your workspace.
          example: list_11aa
        custom:
          type: object
          additionalProperties: { type: string }
          description: >
            Custom personalization values for this lead. Each key becomes a
            `{{token}}` usable in sequence messages, and is registered for your
            workspace automatically if it doesn't exist yet.

            Keys are normalized: lowercased, with runs of spaces or punctuation
            collapsed to underscores, so `Recent Funding` and `recent_funding`
            are the same variable. Keys that collide with a built-in variable
            (firstName, lastName, fullName, company, jobTitle, location,
            headline, email, and the sender* set) are ignored, set those through
            their own fields instead. Values are trimmed and truncated at 500
            characters.

            Re-sending an existing lead MERGES these values rather than
            replacing them, so adding one new field later enriches the lead
            instead of clearing the others.
          example:
            recent_funding: Series B
            industry: fintech

    LeadList:
      type: object
      properties:
        id: { type: string, example: list_11aa }
        workspaceId: { type: string }
        createdBy: { type: string }
        name: { type: string }
        source: { type: string, enum: [csv, linkedin_urls, linkedin_search, manual] }
        sourceMetadata: { type: object, description: "Per-source detail; the optional description is stashed here." }
        leadCount: { type: integer }
        campaignsUsing: { type: integer }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    LeadListCreate:
      type: object
      required: [name]
      properties:
        name: { type: string }
        description: { type: string }
        source: { type: string, enum: [csv, linkedin_urls, linkedin_search, manual], default: manual }
    LeadListUpdate:
      type: object
      properties:
        name: { type: string }
        description: { type: string }
    LeadListAddLeads:
      type: object
      required: [source, data]
      properties:
        source: { type: string, enum: [csv, linkedin_urls] }
        data:
          type: object
          description: "For `csv`: `{rows: [...]}`. For `linkedin_urls`: `{urls: [...]}`."

    Account:
      type: object
      description: A connected LinkedIn account. Internal provider identifiers are stripped from this view.
      properties:
        id: { type: string, example: acc_8f21c0 }
        workspaceId: { type: string }
        addedByEmail: { type: string }
        provider: { type: string, example: linkedin }
        pictureUrl: { type: string }
        name: { type: string }
        linkedinEmail: { type: string }
        status: { type: string, description: "Last status reported by the provider (OK, CREDENTIALS, CONNECTING, STOPPED, ...)." }
        enabled: { type: boolean }
        dailyLimits: { type: object }
        tags: { type: array, items: { type: string } }
        schedule: { type: object }
        metadata: { type: object }
        connectedAt: { type: string, format: date-time }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    AccountList:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Account" }
        meta: { $ref: "#/components/schemas/Meta" }
    AccountHealth:
      type: object
      properties:
        account: { $ref: "#/components/schemas/Account" }
        schedule: { $ref: "#/components/schemas/ScheduleStatus" }
        usage:
          type: array
          items: { $ref: "#/components/schemas/ActionUsage" }
        platform: { $ref: "#/components/schemas/PlatformLimits" }
    ScheduleStatus:
      type: object
      properties:
        allowedNow: { type: boolean }
        reason: { type: string, description: "Human label: Active / Outside hours / Day off / Skipped (date) / Skipped (random)." }
        localTime: { type: string }
        localDate: { type: string }
        localWeekday: { type: string }
    ActionUsage:
      type: object
      properties:
        actionType: { $ref: "#/components/schemas/ActionType" }
        used: { type: integer }
        limit: { type: integer, description: "Rolled quota for today." }
        range: { $ref: "#/components/schemas/LimitRange" }
        weekly:
          allOf: [{ $ref: "#/components/schemas/WeeklyUsage" }]
          description: |
            Present on `connection_invite` only, and only when a weekly cap is
            configured. LinkedIn counts invitations per WEEK, so this ceiling
            sits on top of the daily quota and is usually the one that actually
            binds. Absent for every other action, check for the key before
            reading into it.
    WeeklyUsage:
      type: object
      description: |
        Rolling 7-day connection-invite usage, and the ceiling Outvelo ENFORCES.
        An account configured for 45 invites/day against a 200/week cap runs at
        full speed for four and a half days and then stops, averaging ~28/day,
        read this before concluding a daily limit is being ignored.

        Note this is NOT the same number as `platform.guidance[].safeWeekly`.
        `cap` here is Outvelo's own enforcement ceiling, currently one global
        value for every account; `safeWeekly` is LinkedIn's advisory figure for
        the account's tier. They can disagree, a free account is advised 80/week
        while the enforcer holds it at 200, and when they do, the advisory
        figure is the safer one to work to.

        Omitted entirely when no weekly cap is configured on the deployment.
      properties:
        used: { type: integer, description: "Invites sent in the trailing 7 account-local days." }
        cap: { type: integer, description: "Outvelo's enforced rolling 7-day ceiling." }
        remaining: { type: integer, description: "cap − used, floored at 0." }
        exhausted: { type: boolean, description: "True while invites are held until the oldest day rolls off." }
        dailyCeiling: { type: integer, description: "Largest sustainable daily average (cap / 7)." }
    ActionType:
      type: string
      enum:
        - connection_invite
        - follow_up_message
        - inmail
        - profile_view
        - profile_follow
        - post_like_comment
        - withdraw_connection_request
    LimitRange:
      type: object
      properties:
        min: { type: integer }
        max: { type: integer }
    AccountSchedule:
      type: object
      properties:
        schedule:
          type: object
          description: "Loose JSON: `{timezone, startTime, endTime, days[], skipRandomDay, skipDates[]}`. Empty when never configured."
    AccountScheduleUpdate:
      type: object
      required: [schedule]
      properties:
        schedule:
          type: object
          description: |
            Working-hours config. `days` uses Sun=0 … Sat=6. While Shield mode
            is on, the window (endTime − startTime) can't exceed 12 hours.
          properties:
            timezone: { type: string, example: America/New_York }
            startTime: { type: string, example: "09:00" }
            endTime: { type: string, example: "21:00" }
            days: { type: array, items: { type: integer } }
            skipRandomDay: { type: boolean }
            skipDates: { type: array, items: { type: string } }
    AccountDailyLimits:
      type: object
      properties:
        limits:
          type: object
          additionalProperties: { $ref: "#/components/schemas/LimitRange" }
          description: "Map of action_type → {min, max}. What YOU have configured."
        usage:
          type: array
          items: { $ref: "#/components/schemas/ActionUsage" }
          description: "Today's counters, plus the rolling weekly block on connection_invite."
        safeMode: { type: boolean, description: "Whether Shield (safe) mode is on." }
        platform: { $ref: "#/components/schemas/PlatformLimits" }
    PlatformLimits:
      type: object
      description: |
        What LINKEDIN tolerates for this account, as opposed to what you have
        configured. Advisory only, nothing here is enforced, and you can save
        limits above these numbers. LinkedIn does not publish its rate limits;
        these are the industry consensus, and the real ceiling moves with
        account standing (acceptance rate above all) rather than subscription.
      properties:
        tier: { type: string, enum: [free, premium, sales_navigator] }
        tierLabel: { type: string, example: "Sales Navigator" }
        tierAsOf:
          type: string
          format: date-time
          description: |
            When the tier was last read from LinkedIn. It is CACHED, captured at
            connect time and on manual refresh, never on a schedule, so an
            account that has since changed plan reports the old value. Omitted
            when we have no record of the read.
        guidance:
          type: array
          items: { $ref: "#/components/schemas/PlatformGuidance" }
        noteCharLimit: { type: integer, example: 300, description: "Connection-note character cap. 300 on every tier." }
        freeMonthlyNotedInvites:
          type: integer
          example: 5
          description: |
            Personalised invites per MONTH on a free account. Omitted on paid
            tiers, which have no monthly note cap. Past the quota, connection
            requests still send but without a note.
    PlatformGuidance:
      type: object
      properties:
        action: { $ref: "#/components/schemas/ActionType" }
        safeDaily: { type: integer, description: "Suggested daily maximum for this tier." }
        safeWeekly: { type: integer, description: "Weekly ceiling, where one applies." }
        hardDaily: { type: integer, description: "Point at which visible enforcement is reported. Reserved, not currently populated for any action, since no figure is well enough attested to publish." }
        note: { type: string, description: "Plain-language explanation, safe to show a user verbatim." }
    AccountDailyLimitsUpdate:
      type: object
      description: |
        Provide `limits` to set ranges, or `safeMode` to toggle Shield mode
        (which, when present, takes precedence and ignores `limits`).
      properties:
        limits:
          type: object
          additionalProperties:
            type: object
            properties:
              min: { type: integer }
              max: { type: integer }
          description: |
            Map of `action_type` → `{min, max}`. Valid action types:
            connection_invite, follow_up_message, inmail, profile_view,
            profile_follow, post_like_comment, withdraw_connection_request.
        safeMode: { type: boolean }

    Conversation:
      type: object
      properties:
        id: { type: string, example: conv_aa11 }
        workspaceId: { type: string }
        accountId: { type: string }
        campaignId: { type: string }
        participantName: { type: string }
        participantHeadline: { type: string }
        participantCompany: { type: string }
        participantEmail: { type: string }
        participantPhone: { type: string }
        participantLinkedinUrl: { type: string }
        participantPicture: { type: string }
        status: { type: string, enum: [unread, replied, paused, archived] }
        tags: { type: array, items: { type: string } }
        unreadCount: { type: integer }
        isInmail: { type: boolean }
        lastMessageAt: { type: string, format: date-time }
        lastMessageSnippet: { type: string }
        source: { type: string, enum: [linkedin, sales_navigator] }
        metadata: { type: object }
        currentStep: { type: integer, description: "Joined from the matching prospect; 0 when not tied to a campaign." }
        totalSteps: { type: integer }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    ConversationList:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Conversation" }
        meta: { $ref: "#/components/schemas/Meta" }
    ConversationDetail:
      type: object
      properties:
        conversation: { $ref: "#/components/schemas/Conversation" }
        messages:
          type: array
          items: { $ref: "#/components/schemas/Message" }
    ConversationUpdate:
      type: object
      properties:
        status: { type: string, enum: [unread, replied, paused, archived] }
        tags: { type: array, items: { type: string } }
    Message:
      type: object
      properties:
        id: { type: string, example: msg_3 }
        conversationId: { type: string }
        providerMessageId: { type: string }
        direction: { type: string, enum: [in, out] }
        body: { type: string }
        sentAt: { type: string, format: date-time }
        createdAt: { type: string, format: date-time }
        deliveredAt: { type: string, format: date-time }
        readAt: { type: string, format: date-time }
        editedAt: { type: string, format: date-time }
        deleted: { type: boolean }
        reaction: { type: string }
        attachments:
          type: array
          items: { $ref: "#/components/schemas/MessageAttachment" }
        hidden: { type: boolean }
    MessageAttachment:
      type: object
      properties:
        id: { type: string }
        type: { type: string, description: "img | file | video | audio | linkedin_post | ..." }
        mimetype: { type: string }
        fileName: { type: string }
        width: { type: integer }
        height: { type: integer }
        durationSec: { type: integer }
        sticker: { type: boolean }
        unavailable: { type: boolean }
    SendMessage:
      type: object
      required: [body]
      properties:
        body: { type: string }

    Webhook:
      type: object
      description: A webhook subscription. The signing `secret` is never included here.
      properties:
        id: { type: string, example: wh_5b2a91 }
        workspaceId: { type: string }
        name: { type: string }
        url: { type: string, format: uri }
        events:
          type: array
          items: { type: string }
          description: "Subscribed events; empty/`['*']` means all events."
        campaignIds: { type: array, items: { type: string }, description: "Scope delivery to specific campaigns; empty = all." }
        headers: { type: object, description: "Custom HTTP headers sent with every delivery." }
        active: { type: boolean }
        lastFiredAt: { type: string, format: date-time }
        lastFailureAt: { type: string, format: date-time }
        lastFailure: { type: string }
        createdBy: { type: string }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    WebhookWithSecret:
      allOf:
        - $ref: "#/components/schemas/Webhook"
        - type: object
          properties:
            secret: { type: string, description: "Signing secret, returned ONCE (create + rotate)." }
    WebhookCreate:
      type: object
      required: [url]
      properties:
        url: { type: string, format: uri }
        events:
          type: array
          items:
            type: string
            enum:
              - lead.replied
              - lead.bounced
              - connection.accepted
              - campaign.idle
              - lead.step_completed
          description: Empty array = subscribe to every event.
        active: { type: boolean, default: true }
    WebhookUpdate:
      type: object
      properties:
        url: { type: string, format: uri }
        events:
          type: array
          items: { type: string }
        active: { type: boolean }
    WebhookTestResult:
      type: object
      properties:
        ok: { type: boolean }
        webhookId: { type: string }
        url: { type: string }
        note: { type: string }

    WorkspaceStats:
      type: object
      properties:
        totals:
          type: object
          properties:
            campaigns: { type: integer }
            active_campaigns: { type: integer }
            requests_sent: { type: integer }
            requests_accepted: { type: integer }
            replied_count: { type: integer }
            profiles_touched: { type: integer }
            total_prospects: { type: integer }
        rates:
          type: object
          properties:
            acceptance: { type: number, format: float, description: "requests_accepted / requests_sent." }
            reply: { type: number, format: float, description: "replied_count / requests_accepted." }
    CampaignStats:
      type: object
      properties:
        stats: { type: object, description: "Raw counters set by the runner." }
        funnel: { $ref: "#/components/schemas/CampaignFunnel" }
        status: { type: string, enum: [draft, active, paused, completed] }
    AccountStats:
      type: object
      properties:
        account: { type: string, description: "Account id." }
        usage:
          type: array
          items: { $ref: "#/components/schemas/ActionUsage" }

    Me:
      type: object
      properties:
        apiKey:
          type: object
          nullable: true
          properties:
            id: { type: string }
            name: { type: string }
            prefix: { type: string }
            scope: { type: string, enum: [read, write, admin] }
            rateLimit: { type: integer, description: "Per-second request cap." }
            lastUsedAt: { type: string, format: date-time }
        workspace:
          type: object
          properties:
            id: { type: string }
            name: { type: string }
