{
  "openapi": "3.1.0",
  "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
  "info": {
    "title": "Cornersight Public API",
    "version": "1.0.0",
    "summary": "Public asynchronous API for Cornersight enrichment, post, engagement, and job status workflows.",
    "description": "The Cornersight Public API is asynchronous for post and enrichment endpoints. Operational endpoints return a `jobId`; clients should call `GET /api/v1/jobs/{jobId}/status` until the job reaches `completed` or `failed`. Profile enrichment jobs consume one enrichment credit for each matching lead that is successfully enriched. Leads already enriched for the tracked profile are not charged again; failed or empty enrichment attempts are not charged. Engagement endpoints (`/post/reactions`, `/post/comments`, and `/post/company-comments`) process the job immediately in a per-team queue and return a `jobId` that is already `completed` or `failed`. All endpoints require the `X-API-Key` header. The authenticated team must have an active subscription or an unexpired trial; otherwise requests return `403`. The key is generated in Cornersight Settings and is expected to use the `cs_` prefix, for example `cs_<your-key>`. Official CLI package: install with `npm install -g cornersight-cli` or run with `npx cornersight-cli`. Example: `npx cornersight-cli enrich-profile --api-key cs_<your-key> --username demo-profile --save-tracked-profile`. The CLI prints JSON, waits for jobs by default, and supports `--no-wait` to return the accepted `jobId` immediately. To use the same job workflows from ChatGPT or Claude, connect the Cornersight MCP server with the MCP setup guide. Point the client at `https://mcp.cornersight.io/mcp` and either sign in to Cornersight when it asks (connector UIs such as Claude and ChatGPT run the OAuth flow from the URL alone), or send this SAME `cs_` key as a header \u2014 either `X-API-Key: cs_<your-key>` or `Authorization: Bearer cs_<your-key>`. Prefer a header over the URL, because a key in the URL leaks into proxy/access logs, browser history, and `Referer` headers. Clients that support neither sign-in nor headers can fall back to `https://mcp.cornersight.io/mcp/<your-key>`, which authenticates identically \u2014 treat that entire URL as a secret. **Warning:** the interactive playground sends real requests and can create jobs. **Errors:** every error body is JSON with an `error` message and, where the failure has a stable name, a `code` to branch on. A `POST`/`PUT`/`PATCH` body that is not valid JSON, or is JSON but not an object, is `400` with `code: \"invalid_json\"` before any field is read (an empty body is read as `{}`). A path no endpoint serves is `404` `{ \"error\": \"No such endpoint\", \"code\": \"not_found\" }`; an existing path asked with a method it does not serve is `405` with `code: \"method_not_allowed\"`, the served methods in `allowed`, and an `Allow` header. **Rate limits:** requests are rate limited per team (per API key \u2014 MCP tool calls share the same budget), with separate windows for reads (`GET`) and writes (all other methods). Every `/api/v1/*` response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (epoch seconds). Exceeding the limit returns `429 Too Many Requests` with a `Retry-After` header (seconds) and `{ \"error\", \"code\": \"rate_limited\" }`; wait for the window to reset and retry. Defaults are generous for normal use (120 reads/min, 60 writes/min) and can be raised for your team on request. \u26a0\ufe0f THE TWO POSTS READS DRAW ON A THIRD, TIGHTER BUCKET: GET /api/v1/profile/{username}/posts and GET /api/v1/company/{username}/posts share ONE budget of 10 CALLS A MINUTE per team, because each one fans out to a paid upstream. Those two are also CHARGED \u2014 one credit per post returned \u2014 so a 429 on either means called-too-fast and an out-of-credits condition is a 402 `insufficient_enriching_credits` with its own code; the two are different answers and must not be confused."
  },
  "externalDocs": {
    "description": "Connect the Cornersight MCP server to ChatGPT or Claude.",
    "url": "/mcp/setup"
  },
  "servers": [
    {
      "url": "/"
    }
  ],
  "security": [
    {
      "ApiKeyAuth": []
    }
  ],
  "tags": [
    {
      "name": "Agent",
      "description": "The Engagement Agent: set its profile, topics and webhook, start it (one daily keyword search per topic plus a one-off search for the people to watch, within one daily credit limit; 1,000 people checked on a free trial), stop it, read its leads ranked by ICP % and signal, and send them to its webhook. Several agents per team, one per website. Drafting a profile from a website is dashboard-only."
    },
    {
      "name": "Integrations",
      "description": "HeyReach: read the connection, list campaigns, send leads (and Discover influencers) to a campaign, and set a source's auto-push; an agent's auto-push is `heyreach` on PUT /api/v1/agent. Connecting a key is dashboard-only. Only ENRICHED leads are sent; a lead with no LinkedIn profile URL is skipped (`skippedNoLinkedin`); a person is never sent twice to the same campaign (`alreadyInCampaign`); a paused campaign is never resumed, and a finished one is never restarted."
    },
    {
      "name": "Enrichment",
      "description": "Create asynchronous enrichment jobs."
    },
    {
      "name": "Tracked Profiles",
      "description": "Manage tracked LinkedIn profiles and company pages \u2014 untrack (delete) ones you no longer want synced."
    },
    {
      "name": "Leads",
      "description": "Query the engagers Cornersight has already captured and enriched for your tracked profiles."
    },
    {
      "name": "Discover",
      "description": "Discover Influencers: a ONE-OFF search for the people who POST about a topic and get engagement on it. Each person found is added to your leads as an Author lead (`engagementType: \"Author\"`) and appears in Influencer Leads in the dashboard (left rail: \"Discover Influencers\" starts a search, \"Influencer Leads\" holds the results). It is not a recurring search and not the people who react to posts; for those, use a keyword search (`POST /api/v1/keyword/track`).\n\nTOPIC. A plain list of 1-10 keywords, and a post that matches ANY of them counts. There is no AND or NOT on Discover. Each keyword is a separate LinkedIn search. Keywords may not contain quotation marks or brackets.\n\nWINDOW AND ORDER. Every run looks back ONE MONTH and sorts by RELEVANCE, not newest first, so the posts come from across the whole month. Neither is configurable. Roughly 250-700 posts are read per keyword; the exact number depends on what the provider returns.\n\nHOW ENGAGEMENT IS MEASURED. Engagement on a post = likes (every reaction type) + comments. Reposts and views are not counted. A person's AVERAGE = the sum over their matching posts / the number of their matching posts; most people have one matching post, so the average equals that post. `minEngagement` is applied to the average. HIGHEST ENGAGEMENT = likes + comments on the person's best matching post: it is what the Influencer Leads table shows and ranks by, and `highestEngagement` on GET /api/v1/discover/{id}.\n\nCOMPANY PAGES ARE SKIPPED. Only people are found and added.\n\nMAXIMUM INFLUENCERS (`maxInfluencers`, 1-500). The most people one run adds. When more qualify, the highest averages are added first, and the run reports how many more qualified but were cut: `qualified` greater than `found` (the dashboard says \"N more people qualified, but the run stopped at its limit of M\").\n\nCOUNTRIES (optional, up to 20). Only people whose LinkedIn profile is in one of these countries are added. Matching ignores case, spacing and accents; knows common short forms (UK, GB, England, Scotland, Wales and Northern Ireland are the United Kingdom; US and USA the United States; UAE the United Arab Emirates; Holland the Netherlands; Deutschland Germany; Czechia the Czech Republic; Turkiye Turkey; NZ New Zealand; KSA Saudi Arabia); finds a country inside a full location (\"South Delhi, Delhi, India\" is India); and matches whole words only (\"India\" never matches \"Indiana\"). Bare \"America\" and \"Korea\" are deliberately not aliases. Countries are saved cleaned (\"uk\" is saved as \"United Kingdom\"). Profiles are looked up BEFORE anyone is added, best average first, and at most min(500, max(100, 5 x maxInfluencers)) people are looked up per run. A person with no country on their profile is not added. Lookups cost you nothing. The run reports `countryChecked` (people looked up) and `countryMatched` (people in your countries).\n\nCREDITS. 1 credit per influencer added, charged once. Reading posts and country lookups are free. A create without `\"confirmSpend\": true` is refused with a 409 `spend_confirmation_required` quoting `estimatedCredits` (= maxInfluencers, the most the run can spend) and creates nothing.\n\nLIFECYCLE. A run is `running` until it finishes, then `done` or `failed` (a done run that found nobody shows \"No results\" in the dashboard). Most runs finish within a few minutes. Each influencer carries name, LinkedIn URL, avatar, job title, company and country (filled by enrichment shortly after capture: null here, and \"Enriching...\" in the dashboard, until then), highest engagement, average engagement, post count, total engagement, and the top post's URL and text; the run carries its keywords (the topic). DELETE is a soft delete: the run and its influencers leave the lists, but the Author leads it already added stay in your leads and no credit is refunded. From Influencer Leads in the dashboard, Track adds a person as a tracked profile.\n\nIN THE DASHBOARD. Discover Influencers: 1 Topic (add keywords), 2 Engagement (Minimum average engagement), 3 Location (optional countries), 4 Results (Maximum influencers), then Find influencers. Your Searches lists every run with its state, View Leads (opens Influencer Leads for that search) and Delete; tick several to delete them together. The same feature is the MCP tools discover_influencers, list_discover_runs, get_discover_run and delete_discover_run, and the CLI commands discover, discover-list, discover-get and discover-delete."
    },
    {
      "name": "Posts",
      "description": "Create asynchronous jobs that fetch posts for tracked LinkedIn profiles or company pages."
    },
    {
      "name": "Engagements",
      "description": "Create asynchronous jobs that fetch reactions or comments for tracked posts."
    },
    {
      "name": "Jobs",
      "description": "Poll asynchronous Public API jobs."
    },
    {
      "name": "Credits",
      "description": "Read enriching credit balance and usage."
    },
    {
      "name": "Account",
      "description": "Manage this team's API keys: list the active ones (masked), mint a new one, and revoke by id. A minted key's plaintext is returned ONCE and cannot be retrieved again."
    },
    {
      "name": "Webhooks",
      "description": "Outbound events Cornersight POSTs to your endpoint, and the operations that control their DELIVERY. Future leads are delivered automatically by the tracked source's `autoSend` flag (set with the webhook config operations under Tracked Profiles); already-captured leads are delivered by an explicit push, which is what `autoSend: false` means and what the operations in this section do. Saving or changing a webhook URL through this API never delivers history by itself."
    }
  ],
  "paths": {
    "/api/v1/enrich/profile": {
      "post": {
        "tags": [
          "Enrichment"
        ],
        "summary": "Create a profile enrichment job",
        "operationId": "createProfileEnrichmentJob",
        "description": "Creates an asynchronous job to enrich a personal LinkedIn profile. Each matching lead that is successfully enriched consumes one enrichment credit. Leads already enriched for the tracked profile and failed or empty enrichments are not charged. On completion, the job result includes creditsCharged \u2014 the number of enriching credits this call actually consumed (0 when every matching lead was already enriched). When saveTrackedProfile=true, tracking is automatic end to end: Cornersight queues a background sync that fetches the profile's recent posts and captures ALL their engagement (reactions and comments) as leads \u2014 no separate posts/reactions/comments calls are needed to collect engagement. The sync is QUEUED, not instant: duration depends on current API load and how many posts and engagements the source has, and leads appear progressively, so an empty lead list shortly after tracking does not mean it failed. The response returns a `syncId` when a sync was queued; poll GET /api/v1/{profile|company}/{username}/sync for its stage and progress. RE-TRACKING A SOURCE THAT HAS ALREADY SYNCED DOES NOT RE-SYNC IT: no sync is queued, the response says so with `syncId: null` and `syncNotQueuedReason`, the settings you sent apply from the next scheduled run (POST /api/v1/sources/{id}/sync syncs it now, charged like any sync), and the job enriches only the profile itself. The same full capture then repeats on the daily sync. The reactions and comments endpoints remain available for immediate, targeted single-page pulls. THE `entityUrn` IN THE RESULT IS A LINKEDIN MEMBER ID (`ACoAA\u2026`), and it is exactly the value `fromPerson` and `mentionsPerson` take on POST /api/v1/keyword/track \u2014 pass it bare or wrapped as `urn:li:person:<id>`; both are accepted. \u26a0\ufe0f DO NOT USE THIS ENDPOINT MERELY TO RESOLVE AN ID: without `saveTrackedProfile` it answers 404 for anyone who is not already one of your leads, and WITH it, it tracks them \u2014 a full sync, queued and charged. `GET /api/v1/profile/{username}/urn` resolves any public handle for free instead.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ProfileEnrichmentRequest"
              },
              "examples": {
                "updateExistingLead": {
                  "summary": "Update an existing lead",
                  "value": {
                    "username": "demo-profile",
                    "saveTrackedProfile": false
                  }
                },
                "saveTrackedProfile": {
                  "summary": "Create or update a tracked profile first",
                  "value": {
                    "username": "demo-profile",
                    "saveTrackedProfile": true
                  }
                },
                "postsOnlyWatch": {
                  "summary": "Watch this source's posts daily instead of sweeping its engagers",
                  "value": {
                    "username": "demo-profile",
                    "saveTrackedProfile": true,
                    "mode": "posts_only",
                    "postsPerSync": 5,
                    "confirmSpend": true
                  }
                },
                "firstSyncWindow": {
                  "summary": "Track a profile whose first sync collects the last 30 days, at most 20 posts",
                  "value": {
                    "username": "demo-profile",
                    "saveTrackedProfile": true,
                    "firstSyncDays": 30,
                    "firstSyncPosts": 20
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Job accepted. Poll the status endpoint while the job is `pending` or `running`, until it reaches `completed` or `failed`.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/JobAccepted"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "creditCapPerSync": {
                          "type": [
                            "integer",
                            "null"
                          ],
                          "description": "ECHOED ONLY WHEN THE REQUEST NAMED IT \u2014 the per-sync limit now stored on the tracked source. It is written synchronously, before the job is queued, so this is a statement about the source row rather than about the job. Absent when the body carried no `creditCapPerSync`, which is the shape this endpoint has always returned. Per sync, every sync \u2014 the capture cap counts lead rows, while billing charges once per new person per source, with no estimate, no confirmation gate and no team ceiling: the team's `dailyCeiling` counts KEYWORD spend only. Not a keyword search's `creditCap`, which is the daily bound on a recurring sweep and has all three."
                        },
                        "enrichLeads": {
                          "type": "boolean",
                          "description": "ECHOED ONLY WHEN THE REQUEST NAMED IT \u2014 the raw-mode setting now stored on the tracked source (false = raw: captured and charged, never enriched, read with GET /api/v1/leads/raw). Written synchronously, before the job is queued."
                        },
                        "syncId": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "description": "PRESENT ONLY WHEN `saveTrackedProfile` IS TRUE. The id of the capture sync this call queued \u2014 poll GET /api/v1/{profile|company}/{username}/sync \u2014 or `null` when none was queued, which happens when the source was ALREADY TRACKED AND HAS SYNCED BEFORE: re-tracking does not re-sync, and `syncNotQueuedReason` beside it says so. Absent when the call tracked nothing."
                        },
                        "syncNotQueuedReason": {
                          "type": "string",
                          "description": "PRESENT ONLY beside `syncId: null`, when `saveTrackedProfile: true` named a source that is already tracked and has synced before. A sentence saying no sync was queued, that the source runs on its daily schedule (naming the next run when there is one), and that any setting sent with the call \u2014 `creditCapPerSync`, `mode`, `captureReplies`, `enrichLeads` \u2014 applies from that run, and that POST /api/v1/sources/{id}/sync syncs it now. That route is the on-demand sync, charged like any sync; to change a setting without a job, use PATCH /api/v1/{profile|company}/{username}."
                        },
                        "firstSyncPosts": {
                          "type": [
                            "integer",
                            "null"
                          ],
                          "description": "ECHOED ONLY WHEN THE REQUEST NAMED IT - the first-sync post count now stored on the tracked source (`null` = the default, the latest 15 posts). Written synchronously, before the job is queued; it binds the source's FIRST sync only."
                        },
                        "firstSyncDays": {
                          "type": [
                            "integer",
                            "null"
                          ],
                          "description": "ECHOED ONLY WHEN THE REQUEST NAMED IT - the first-sync days window now stored on the tracked source (`null` = no time limit). Written synchronously, before the job is queued; it binds the source's FIRST sync only."
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "accepted": {
                    "value": {
                      "jobId": "00000000-0000-4000-8000-000000000001"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/MissingUsername"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/SubscriptionInactive"
          },
          "404": {
            "$ref": "#/components/responses/LeadNotFound"
          },
          "409": {
            "description": "TWO REFUSALS SHARE THIS STATUS AND `code` TELLS THEM APART. \n\n`spend_confirmation_required` \u2014 a posts-only source was asked for without `confirmSpend`. Nothing was created and nothing was charged. The body carries `mode`, `postsPerSync`, `estimatedDailyMax` (equal to `postsPerSync`), `daysToExhaustAtCap` and `remainingBalance`; say the daily figure to whoever is paying, wait for an answer, then re-send the identical request with `confirmSpend: true`. The same code POST /api/v1/keyword/track answers with, deliberately.\n\nCode `identifier_in_use`. Only with `saveTrackedProfile: true`. A source identifier is unique per team across all four source kinds, and this handle is already held by a source of a DIFFERENT kind \u2014 a keyword search whose joined terms are that handle, or a tracked post \u2014 so the tracked profile cannot be created. The message names the source holding it. Nothing was created, no credit was charged, and the enrichment did not run.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/v1/enrich/company": {
      "post": {
        "tags": [
          "Enrichment"
        ],
        "summary": "Create a company enrichment job",
        "operationId": "createCompanyEnrichmentJob",
        "description": "Creates an asynchronous job to enrich a LinkedIn company page. Company enrichment never consumes enriching credits; the completed job result includes creditsCharged: 0. When saveTrackedProfile=true, tracking is automatic end to end: Cornersight queues a background sync that fetches the profile's recent posts and captures ALL their engagement (reactions and comments) as leads \u2014 no separate posts/reactions/comments calls are needed to collect engagement. The sync is QUEUED, not instant: duration depends on current API load and how many posts and engagements the source has, and leads appear progressively, so an empty lead list shortly after tracking does not mean it failed. The response returns a `syncId` when a sync was queued; poll GET /api/v1/{profile|company}/{username}/sync for its stage and progress. RE-TRACKING A SOURCE THAT HAS ALREADY SYNCED DOES NOT RE-SYNC IT: no sync is queued, the response says so with `syncId: null` and `syncNotQueuedReason`, the settings you sent apply from the next scheduled run (POST /api/v1/sources/{id}/sync syncs it now, charged like any sync), and the job enriches only the profile itself. The same full capture then repeats on the daily sync. The reactions and comments endpoints remain available for immediate, targeted single-page pulls. On completion the job `result` carries the full firmographic set (industry, employee count and size range, headquarters, description, founded year, follower count, specialties, and a stable company URN) \u2014 see the CompanyEnrichmentResult schema. BREAKING (2026-07-18): these fields are now FLAT on `result`, matching profile enrichment; the previous `result.data` envelope and the capital-I `Images` key are gone (`images.logo` now).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CompanyEnrichmentRequest"
              },
              "examples": {
                "updateExistingCompanyLeads": {
                  "summary": "Update existing company leads",
                  "value": {
                    "username": "demo-company",
                    "saveTrackedProfile": false
                  }
                },
                "saveTrackedCompany": {
                  "summary": "Create or update a tracked company first",
                  "value": {
                    "username": "demo-company",
                    "saveTrackedProfile": true
                  }
                },
                "postsOnlyWatch": {
                  "summary": "Watch this source's posts daily instead of sweeping its engagers",
                  "value": {
                    "username": "instantlyapp",
                    "saveTrackedProfile": true,
                    "mode": "posts_only",
                    "postsPerSync": 5,
                    "confirmSpend": true
                  }
                },
                "firstSyncWindow": {
                  "summary": "Track a company page whose first sync collects its latest 5 posts",
                  "value": {
                    "username": "demo-company",
                    "saveTrackedProfile": true,
                    "firstSyncPosts": 5
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Job accepted. Poll the status endpoint while the job is `pending` or `running`, until it reaches `completed` or `failed`.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/JobAccepted"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "creditCapPerSync": {
                          "type": [
                            "integer",
                            "null"
                          ],
                          "description": "ECHOED ONLY WHEN THE REQUEST NAMED IT \u2014 the per-sync limit now stored on the tracked source. It is written synchronously, before the job is queued, so this is a statement about the source row rather than about the job. Absent when the body carried no `creditCapPerSync`, which is the shape this endpoint has always returned. Per sync, every sync \u2014 the capture cap counts lead rows, while billing charges once per new person per source, with no estimate, no confirmation gate and no team ceiling: the team's `dailyCeiling` counts KEYWORD spend only. Not a keyword search's `creditCap`, which is the daily bound on a recurring sweep and has all three."
                        },
                        "enrichLeads": {
                          "type": "boolean",
                          "description": "ECHOED ONLY WHEN THE REQUEST NAMED IT \u2014 the raw-mode setting now stored on the tracked source (false = raw: captured and charged, never enriched, read with GET /api/v1/leads/raw). Written synchronously, before the job is queued."
                        },
                        "syncId": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "description": "PRESENT ONLY WHEN `saveTrackedProfile` IS TRUE. The id of the capture sync this call queued \u2014 poll GET /api/v1/{profile|company}/{username}/sync \u2014 or `null` when none was queued, which happens when the source was ALREADY TRACKED AND HAS SYNCED BEFORE: re-tracking does not re-sync, and `syncNotQueuedReason` beside it says so. Absent when the call tracked nothing."
                        },
                        "syncNotQueuedReason": {
                          "type": "string",
                          "description": "PRESENT ONLY beside `syncId: null`, when `saveTrackedProfile: true` named a source that is already tracked and has synced before. A sentence saying no sync was queued, that the source runs on its daily schedule (naming the next run when there is one), and that any setting sent with the call \u2014 `creditCapPerSync`, `mode`, `captureReplies`, `enrichLeads` \u2014 applies from that run, and that POST /api/v1/sources/{id}/sync syncs it now. That route is the on-demand sync, charged like any sync; to change a setting without a job, use PATCH /api/v1/{profile|company}/{username}."
                        },
                        "firstSyncPosts": {
                          "type": [
                            "integer",
                            "null"
                          ],
                          "description": "ECHOED ONLY WHEN THE REQUEST NAMED IT - the first-sync post count now stored on the tracked source (`null` = the default, the latest 15 posts). Written synchronously, before the job is queued; it binds the source's FIRST sync only."
                        },
                        "firstSyncDays": {
                          "type": [
                            "integer",
                            "null"
                          ],
                          "description": "ECHOED ONLY WHEN THE REQUEST NAMED IT - the first-sync days window now stored on the tracked source (`null` = no time limit). Written synchronously, before the job is queued; it binds the source's FIRST sync only."
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "accepted": {
                    "value": {
                      "jobId": "00000000-0000-4000-8000-000000000002"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/MissingUsername"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/SubscriptionInactive"
          },
          "404": {
            "$ref": "#/components/responses/CompanyLeadNotFound"
          },
          "409": {
            "description": "TWO REFUSALS SHARE THIS STATUS AND `code` TELLS THEM APART. \n\n`spend_confirmation_required` \u2014 a posts-only source was asked for without `confirmSpend`. Nothing was created and nothing was charged. The body carries `mode`, `postsPerSync`, `estimatedDailyMax` (equal to `postsPerSync`), `daysToExhaustAtCap` and `remainingBalance`; say the daily figure to whoever is paying, wait for an answer, then re-send the identical request with `confirmSpend: true`. The same code POST /api/v1/keyword/track answers with, deliberately.\n\nCode `identifier_in_use`. Only with `saveTrackedProfile: true`. A source identifier is unique per team across all four source kinds, and this handle is already held by a source of a DIFFERENT kind \u2014 a keyword search whose joined terms are that handle, or a tracked post \u2014 so the tracked profile cannot be created. The message names the source holding it. Nothing was created, no credit was charged, and the enrichment did not run.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/v1/profile/{username}": {
      "patch": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Change a tracked person's per-sync credit limit",
        "operationId": "updateProfile",
        "description": "Change a tracked personal profile's PER-SYNC CREDIT LIMIT without re-tracking it. THE EDIT THAT COSTS NOTHING: `creditCapPerSync` otherwise reaches a personal profile only through POST /api/v1/enrich/profile, which also runs an enrichment job \u2014 and which, on a source that has already synced, queues NO sync: the setting simply applies from the next scheduled run. This route writes the column and nothing else: no sync is queued, no lead is enriched, nothing is charged.\n\nADDRESSED BY THE SAME USERNAME the DELETE on this path takes, so a caller already holds the identifier. An earlier release put this edit on PATCH /api/v1/sources/{id} instead, keyed by source id; that route is REMOVED in favour of these two, which is a breaking change.\n\nAN EDIT BINDS FROM THE NEXT SYNC, NEVER THE ONE ALREADY RUNNING. A run reads the source's limit when it starts and holds it for the whole run, so a cap lowered mid-sweep does not cut that sweep short. Whether the run you are looking at actually ended at the limit is reported by GET /api/v1/profile/{username}/sync as `stoppedBy: \"credit_cap\"`.\n\nNOT A KEYWORD SEARCH'S CAP. `creditCap` is a keyword search's per-RUN limit, it lives on keyword_searches, PATCH /api/v1/keyword/{id} writes it and GET /api/v1/sources reports it as `config.creditCap`. The two fields shared the name `creditCap` until this release; they no longer do, and there is no alias \u2014 a body still sending `creditCap` here is a 400 carrying `code: \"renamed_field\"` rather than a 200 that discarded it.\n\nAT LEAST ONE SETTING IS REQUIRED: `creditCapPerSync` (which may be null), `mode`, `captureReplies` or `enrichLeads`. Absent means \"leave it alone\" everywhere else in this API, and a request that changes nothing cannot honestly be answered 200 \u2014 so a body naming none of them is a 400, and `null` is the value that REMOVES a limit. `enrichLeads: false` puts the source in RAW MODE (captured and charged as before, never enriched, read with GET /api/v1/leads/raw); like the cap it is an edit that queues nothing, charges nothing and needs no `confirmSpend`, and it applies to leads captured after it. THE MODE CAN BE CHANGED HERE TOO: `mode: \"posts_only\"` with `postsPerSync` (1-60) makes the source a posts-only watch (new posts only, one credit per new post, no leads), and `mode: \"engagers\"` turns it back into the engagers sweep. Switching posts-only to engagers can raise the cost, so it is refused 409 `spend_confirmation_required` until the request is re-sent with `confirmSpend: true`; switching to posts-only never needs it. A `mode` change whose current mode cannot be read is a 502 and changes nothing. UNKNOWN FIELDS ARE REFUSED: any other property is a 400 carrying `code: \"unknown_field\"` and naming it.",
        "parameters": [
          {
            "name": "username",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/LinkedInUsername"
            },
            "description": "Public identifier of the tracked personal profile (not a full URL and not a source id), e.g. demo-profile."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "captureReplies": {
                    "type": "boolean",
                    "description": "Whether this tracked source captures reply authors as leads. False skips replies before lead writes and credits."
                  },
                  "mode": {
                    "type": "string",
                    "enum": [
                      "engagers",
                      "posts_only"
                    ],
                    "description": "Switch what each sync of this source does (tracked_profiles.sync_mode). `engagers` sweeps the source's posts and captures the people who engaged as leads, one credit per NEW person per source. `posts_only` fetches the source's new posts only, newest first, one credit per new post up to `postsPerSync`, and writes no leads. Omit it to leave the mode unchanged. Switching from `posts_only` to `engagers` is a SPEND INCREASE: it is refused 409 `spend_confirmation_required` and nothing is written until the same request is re-sent with `confirmSpend: true`. Switching to `posts_only` can only cost less and never needs it. Binds from the next sync."
                  },
                  "postsPerSync": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 60,
                    "description": "With `mode: \"posts_only\"` only, and REQUIRED with it: the most posts ONE SYNC of the source may fetch, 1-60, which, because one post is one credit, is also the most it can cost in a day. Refused with `mode: \"engagers\"` and refused without `mode` (a body naming only `postsPerSync` reads as engagers mode and is a 400)."
                  },
                  "confirmSpend": {
                    "type": "boolean",
                    "description": "Authorises switching a posts-only source to the engagers sweep, the one change on this route that can raise what the source costs. Without it that switch is refused 409 `spend_confirmation_required`, carrying `previous` (the mode and postsPerSync in force), the requested `mode` and the source's `creditCapPerSync`, and NOTHING is changed: say the cost to the person, wait for a yes, then re-send the identical request with `confirmSpend: true`. Ignored by every other change, and not a setting on its own: a body naming only `confirmSpend` is the 400 for a request that changes nothing."
                  },
                  "enrichLeads": {
                    "type": "boolean",
                    "description": "Whether this source's leads are enriched. Default true, what every source has always done. RAW MODE when false: this source's engagers are still captured and charged exactly as before \u2014 one credit per NEW person per source, repeats free, the same ledger and caps \u2014 but NEVER enriched: no job title, company or country, and no enrichment provider call. Those leads end enrichment status `raw` and are read with GET /api/v1/leads/raw; they never appear in GET /api/v1/leads, /engagers, exports, webhooks or integrations. A plain setting, not a spend change: the price is the same either way, so it never needs `confirmSpend`. It applies to leads captured or processed AFTER the change \u2014 leads already enriched stay enriched and raw leads stay raw. Omit it to leave the stored setting alone."
                  },
                  "creditCapPerSync": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "minimum": 1,
                    "maximum": 2147483647,
                    "description": "The most enriching credits ONE SYNC of this source may spend \u2014 CAPTURE CAP COUNTS LEAD ROWS; BILLING CHARGES ONCE PER NEW PERSON PER SOURCE \u2014 or `null` for NO LIMIT, which is the state of every source nobody set one on. PER SYNC AND NOT A LIFETIME TOTAL: the source re-syncs about every 24 hours and this bounds each of those runs. PER SYNC, EVERY SYNC: it bounds EACH sync of that one person, company page or post \u2014 the capture cap counts lead rows, while billing charges once per new person per source \u2014 not the first pull only and not the life of the source, and there is NO ESTIMATE, NO CONFIRMATION GATE AND NO TEAM CEILING behind it: nothing prices a sync before you set the cap, raising or lowering it never needs `confirmSpend`, and the team's `dailyCeiling` does not count a credit of it. \u26a0\ufe0f A KEYWORD SEARCH'S `creditCap` IS THE OTHER FIELD AND HAS ALL THREE: it is the daily bound on a RECURRING SWEEP, POST /api/v1/keyword/estimate prices it as `estimatedDailyMax`, `confirmSpend` gates a create or a raise with a `409 spend_confirmation_required`, and the team's `dailyCeiling` stops it \u2014 and `dailyCeiling` COUNTS KEYWORD SPEND ONLY, so no number of profile syncs can ever reach it. 0 is refused (migration 147's CHECK is `credit_cap > 0`; a source that syncs and writes nothing is a PAUSED source, which `status` already says), as are fractions and values past int4."
                  }
                }
              },
              "examples": {
                "lower": {
                  "value": {
                    "creditCapPerSync": 250
                  }
                },
                "remove": {
                  "value": {
                    "creditCapPerSync": null
                  }
                },
                "postsOnly": {
                  "summary": "Watch this source's posts only (one credit per new post, at most 5 a day)",
                  "value": {
                    "mode": "posts_only",
                    "postsPerSync": 5
                  }
                },
                "backToEngagers": {
                  "summary": "Switch a posts-only source back to the engagers sweep, after the person confirmed the spend",
                  "value": {
                    "mode": "engagers",
                    "confirmSpend": true
                  }
                },
                "rawMode": {
                  "summary": "Keep this source's leads raw (captured and charged, never enriched)",
                  "value": {
                    "enrichLeads": false
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The limit now in force, and the one it replaced.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "sourceId": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "type": {
                      "type": "string",
                      "enum": [
                        "person"
                      ]
                    },
                    "username": {
                      "type": "string",
                      "description": "The source's stored handle, exactly as GET /api/v1/sources reports it."
                    },
                    "creditCapPerSync": {
                      "type": [
                        "integer",
                        "null"
                      ],
                      "description": "The limit now stored. `null` means no limit. Per sync, every sync \u2014 the capture cap counts lead rows, while billing charges once per new person per source, with no estimate, no confirmation gate and no team ceiling: the team's `dailyCeiling` counts KEYWORD spend only. Not a keyword search's `creditCap`, which is the daily bound on a recurring sweep and has all three."
                    },
                    "mode": {
                      "type": "string",
                      "enum": [
                        "engagers",
                        "posts_only"
                      ],
                      "description": "Present only when the request named `mode`: the mode now stored."
                    },
                    "postsPerSync": {
                      "type": [
                        "integer",
                        "null"
                      ],
                      "description": "Present only when the request named `mode`: the posts-only bound now stored, `null` in engagers mode."
                    },
                    "captureReplies": {
                      "type": "boolean",
                      "description": "Present only when the request named it: the reply-capture setting now stored."
                    },
                    "enrichLeads": {
                      "type": "boolean",
                      "description": "Present only when the request named it: the raw-mode setting now stored (false = raw)."
                    },
                    "previous": {
                      "type": "object",
                      "description": "What it was before this call, so a caller can report the change without having read the source first.",
                      "properties": {
                        "creditCapPerSync": {
                          "type": [
                            "integer",
                            "null"
                          ],
                          "description": "The limit this call replaced \u2014 `null` when the source had none. Per sync, every sync \u2014 the capture cap counts lead rows, while billing charges once per new person per source, with no estimate, no confirmation gate and no team ceiling: the team's `dailyCeiling` counts KEYWORD spend only. Not a keyword search's `creditCap`, which is the daily bound on a recurring sweep and has all three."
                        },
                        "mode": {
                          "type": "string",
                          "enum": [
                            "engagers",
                            "posts_only"
                          ],
                          "description": "The mode before this call. Present only when the request named `mode`."
                        },
                        "postsPerSync": {
                          "type": [
                            "integer",
                            "null"
                          ],
                          "description": "The posts-only bound before this call, `null` in engagers mode. Present only when the request named `mode`."
                        },
                        "captureReplies": {
                          "type": "boolean",
                          "description": "The reply-capture setting before this call. Always present."
                        },
                        "enrichLeads": {
                          "type": "boolean",
                          "description": "The raw-mode setting before this call (true = enriching). Always present."
                        }
                      }
                    }
                  }
                },
                "examples": {
                  "lowered": {
                    "value": {
                      "sourceId": "8f1c\u2026",
                      "type": "person",
                      "username": "demo-profile",
                      "creditCapPerSync": 250,
                      "previous": {
                        "creditCapPerSync": 5000
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "No setting in the body (none of `creditCapPerSync`, `mode`, `captureReplies`, `enrichLeads`), an invalid `mode`, a `postsPerSync` outside 1-60 or sent without `mode: \"posts_only\"`, a value outside 1\u20132147483647, a non-boolean `captureReplies` or `enrichLeads`, an unknown field (`unknown_field`), or the renamed `creditCap` (`renamed_field`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/SubscriptionInactive"
          },
          "404": {
            "description": "No such tracked personal profile for this team, or it has been untracked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "409": {
            "description": "`spend_confirmation_required`: the body switches a posts-only source to the engagers sweep without `confirmSpend: true`. Nothing was written. The body carries `error`, `code`, `previous` ({ mode, postsPerSync }), the requested `mode` and the source's `creditCapPerSync`; say the cost to the person and re-send with `confirmSpend: true` only after a yes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "502": {
            "description": "A `mode` change could not read the source's current mode (or the write failed), so nothing was changed. Retry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      },
      "delete": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Untrack a profile",
        "operationId": "untrackProfile",
        "description": "Stop tracking a personal LinkedIn profile. Synchronous. SOFT DELETE, the same contract as untracking a post or a keyword search: the source is DEACTIVATED (`status: \"inactive\"`), never removed, and nothing it captured is destroyed. It cannot be a row delete \u2014 `leads.tracked_profile_id` is `NOT NULL ... ON DELETE CASCADE`, so deleting the source would take every lead it captured with it, which is exactly the data loss this endpoint stopped causing. ACCESS IS A SEPARATE QUESTION FROM STORAGE, and this endpoint answers both differently: nothing is erased, but the leads it captured STOP BEING SERVED \u2014 GET /api/v1/leads and GET /api/v1/engagers exclude them from the all-sources view and return `404` for its `profileId`/`username`, exactly as the dashboard, its stat cards and its CSV export do, and the source stops being actionable (POST /api/v1/profile/{username}/push and its `/webhook` and `/icp` routes all `404`). `GET /api/v1/sources?includeInactive=true` enumerates what you used to track; that call lists the source and does not serve its leads. THE LEADS HAVE THEIR OWN OPT-IN: pass the same parameter to the endpoints that serve them \u2014 `GET /api/v1/leads?includeInactive=true` and `GET /api/v1/engagers?includeInactive=true` \u2014 and the kept leads are returned and this `username`/`profileId` resolves instead of 404ing. It is an API-only opt-in and the default is unchanged, so the dashboard, its stat cards and its CSV export still show nothing for this source. Reactivating does: re-tracking the same username revives the source and its leads are read again. A `404` here means out of scope for that read, never that anything was erased \u2014 erasure is a support request, not an API call. Keyword searches are the one kind whose leads stay readable after untracking; see DELETE /api/v1/keyword/{id}. The sweep stops, the source leaves GET /api/v1/sources unless you pass `?includeInactive=true`, and re-tracking the same username REVIVES that source rather than creating a second one. Credits already spent are never refunded. Scoped to the authenticated team; a profile the team does not track returns `404`. Trial profiles cannot be deleted (`403`) \u2014 subscribe to a paid plan to manage profiles.\n\nWHAT HAPPENS TO WORK THAT IS ALREADY RUNNING \u2014 the half this used to leave unsaid, and the half that costs money. A SWEEP ALREADY RUNNING IS STOPPED: the run asks whether its source is still tracked at every checkpoint it passes (before each provider call, before each post is harvested, before each chunk of lead rows) and abandons the run at the first checkpoint after the delete. THAT IS \u201cAT THE NEXT CHECKPOINT\u201d, NOT \u201cINSTANTLY\u201d: a provider call already in flight finishes first, and the check is coalesced behind a short window (one second by default), so expect the stop within moments rather than at the instant the delete returns. If the status read itself fails the run CARRIES ON to the next checkpoint \u2014 the control channel fails open on purpose, because abandoning a paying customer's sweep over one timed-out SELECT is the worse error. LEADS ALREADY WRITTEN ARE KEPT, and the run is finalised as `completed` with `stoppedBy: \"untracked\"` on GET /api/v1/sources/{id}/sync (and the username-keyed /sync routes). \u26a0\ufe0f THAT VALUE IS NOT IN `lastRun.stoppedBy` on GET /api/v1/sources and never will be: that enum describes how a keyword SWEEP ended and is a closed set. QUEUED ENRICHMENT IS WRITTEN OFF AND NOT CHARGED: enrichment lands minutes to hours after capture, so the backlog is the part of an untracked source that would otherwise go on billing \u2014 every lead of this source still `pending` or `processing` is marked `skipped` with the reason `source_untracked` and costs no enriching credits. Re-tracking this source returns exactly those leads to `pending` at the start of its next sync, and returns no other skipped lead with it.",
        "parameters": [
          {
            "name": "username",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/LinkedInUsername"
            },
            "description": "Public identifier of the tracked personal profile (not a full URL), e.g. demo-profile."
          }
        ],
        "responses": {
          "200": {
            "description": "Profile untracked and deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Untracked"
                },
                "examples": {
                  "untracked": {
                    "value": {
                      "ok": true,
                      "username": "demo-profile",
                      "profileType": "person",
                      "untracked": true
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/MissingUsername"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TrackedProfileDeleteForbidden"
          },
          "404": {
            "$ref": "#/components/responses/ProfileNotTracked"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/v1/company/{username}": {
      "patch": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Change a tracked company page's per-sync credit limit",
        "operationId": "updateCompany",
        "description": "Change a tracked company page's PER-SYNC CREDIT LIMIT without re-tracking it. THE EDIT THAT COSTS NOTHING: `creditCapPerSync` otherwise reaches a company page only through POST /api/v1/enrich/company, which also runs an enrichment job \u2014 and which, on a source that has already synced, queues NO sync: the setting simply applies from the next scheduled run. This route writes the column and nothing else: no sync is queued, no lead is enriched, nothing is charged.\n\nADDRESSED BY THE SAME USERNAME the DELETE on this path takes, so a caller already holds the identifier. An earlier release put this edit on PATCH /api/v1/sources/{id} instead, keyed by source id; that route is REMOVED in favour of these two, which is a breaking change.\n\nAN EDIT BINDS FROM THE NEXT SYNC, NEVER THE ONE ALREADY RUNNING. A run reads the source's limit when it starts and holds it for the whole run, so a cap lowered mid-sweep does not cut that sweep short. Whether the run you are looking at actually ended at the limit is reported by GET /api/v1/company/{username}/sync as `stoppedBy: \"credit_cap\"`.\n\nNOT A KEYWORD SEARCH'S CAP. `creditCap` is a keyword search's per-RUN limit, it lives on keyword_searches, PATCH /api/v1/keyword/{id} writes it and GET /api/v1/sources reports it as `config.creditCap`. The two fields shared the name `creditCap` until this release; they no longer do, and there is no alias \u2014 a body still sending `creditCap` here is a 400 carrying `code: \"renamed_field\"` rather than a 200 that discarded it.\n\nAT LEAST ONE SETTING IS REQUIRED: `creditCapPerSync` (which may be null), `mode`, `captureReplies` or `enrichLeads`. Absent means \"leave it alone\" everywhere else in this API, and a request that changes nothing cannot honestly be answered 200 \u2014 so a body naming none of them is a 400, and `null` is the value that REMOVES a limit. `enrichLeads: false` puts the source in RAW MODE (captured and charged as before, never enriched, read with GET /api/v1/leads/raw); like the cap it is an edit that queues nothing, charges nothing and needs no `confirmSpend`, and it applies to leads captured after it. THE MODE CAN BE CHANGED HERE TOO: `mode: \"posts_only\"` with `postsPerSync` (1-60) makes the source a posts-only watch (new posts only, one credit per new post, no leads), and `mode: \"engagers\"` turns it back into the engagers sweep. Switching posts-only to engagers can raise the cost, so it is refused 409 `spend_confirmation_required` until the request is re-sent with `confirmSpend: true`; switching to posts-only never needs it. A `mode` change whose current mode cannot be read is a 502 and changes nothing. UNKNOWN FIELDS ARE REFUSED: any other property is a 400 carrying `code: \"unknown_field\"` and naming it.",
        "parameters": [
          {
            "name": "username",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/LinkedInUsername"
            },
            "description": "Public identifier of the tracked company page (not a full URL and not a source id), e.g. demo-company."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "captureReplies": {
                    "type": "boolean",
                    "description": "Whether this tracked source captures reply authors as leads. False skips replies before lead writes and credits."
                  },
                  "mode": {
                    "type": "string",
                    "enum": [
                      "engagers",
                      "posts_only"
                    ],
                    "description": "Switch what each sync of this source does (tracked_profiles.sync_mode). `engagers` sweeps the source's posts and captures the people who engaged as leads, one credit per NEW person per source. `posts_only` fetches the source's new posts only, newest first, one credit per new post up to `postsPerSync`, and writes no leads. Omit it to leave the mode unchanged. Switching from `posts_only` to `engagers` is a SPEND INCREASE: it is refused 409 `spend_confirmation_required` and nothing is written until the same request is re-sent with `confirmSpend: true`. Switching to `posts_only` can only cost less and never needs it. Binds from the next sync."
                  },
                  "postsPerSync": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 60,
                    "description": "With `mode: \"posts_only\"` only, and REQUIRED with it: the most posts ONE SYNC of the source may fetch, 1-60, which, because one post is one credit, is also the most it can cost in a day. Refused with `mode: \"engagers\"` and refused without `mode` (a body naming only `postsPerSync` reads as engagers mode and is a 400)."
                  },
                  "confirmSpend": {
                    "type": "boolean",
                    "description": "Authorises switching a posts-only source to the engagers sweep, the one change on this route that can raise what the source costs. Without it that switch is refused 409 `spend_confirmation_required`, carrying `previous` (the mode and postsPerSync in force), the requested `mode` and the source's `creditCapPerSync`, and NOTHING is changed: say the cost to the person, wait for a yes, then re-send the identical request with `confirmSpend: true`. Ignored by every other change, and not a setting on its own: a body naming only `confirmSpend` is the 400 for a request that changes nothing."
                  },
                  "enrichLeads": {
                    "type": "boolean",
                    "description": "Whether this source's leads are enriched. Default true, what every source has always done. RAW MODE when false: this source's engagers are still captured and charged exactly as before \u2014 one credit per NEW person per source, repeats free, the same ledger and caps \u2014 but NEVER enriched: no job title, company or country, and no enrichment provider call. Those leads end enrichment status `raw` and are read with GET /api/v1/leads/raw; they never appear in GET /api/v1/leads, /engagers, exports, webhooks or integrations. A plain setting, not a spend change: the price is the same either way, so it never needs `confirmSpend`. It applies to leads captured or processed AFTER the change \u2014 leads already enriched stay enriched and raw leads stay raw. Omit it to leave the stored setting alone."
                  },
                  "creditCapPerSync": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "minimum": 1,
                    "maximum": 2147483647,
                    "description": "The most enriching credits ONE SYNC of this source may spend \u2014 CAPTURE CAP COUNTS LEAD ROWS; BILLING CHARGES ONCE PER NEW PERSON PER SOURCE \u2014 or `null` for NO LIMIT, which is the state of every source nobody set one on. PER SYNC AND NOT A LIFETIME TOTAL: the source re-syncs about every 24 hours and this bounds each of those runs. PER SYNC, EVERY SYNC: it bounds EACH sync of that one person, company page or post \u2014 the capture cap counts lead rows, while billing charges once per new person per source \u2014 not the first pull only and not the life of the source, and there is NO ESTIMATE, NO CONFIRMATION GATE AND NO TEAM CEILING behind it: nothing prices a sync before you set the cap, raising or lowering it never needs `confirmSpend`, and the team's `dailyCeiling` does not count a credit of it. \u26a0\ufe0f A KEYWORD SEARCH'S `creditCap` IS THE OTHER FIELD AND HAS ALL THREE: it is the daily bound on a RECURRING SWEEP, POST /api/v1/keyword/estimate prices it as `estimatedDailyMax`, `confirmSpend` gates a create or a raise with a `409 spend_confirmation_required`, and the team's `dailyCeiling` stops it \u2014 and `dailyCeiling` COUNTS KEYWORD SPEND ONLY, so no number of profile syncs can ever reach it. 0 is refused (migration 147's CHECK is `credit_cap > 0`; a source that syncs and writes nothing is a PAUSED source, which `status` already says), as are fractions and values past int4."
                  }
                }
              },
              "examples": {
                "lower": {
                  "value": {
                    "creditCapPerSync": 250
                  }
                },
                "remove": {
                  "value": {
                    "creditCapPerSync": null
                  }
                },
                "postsOnly": {
                  "summary": "Watch this source's posts only (one credit per new post, at most 5 a day)",
                  "value": {
                    "mode": "posts_only",
                    "postsPerSync": 5
                  }
                },
                "backToEngagers": {
                  "summary": "Switch a posts-only source back to the engagers sweep, after the person confirmed the spend",
                  "value": {
                    "mode": "engagers",
                    "confirmSpend": true
                  }
                },
                "rawMode": {
                  "summary": "Keep this source's leads raw (captured and charged, never enriched)",
                  "value": {
                    "enrichLeads": false
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The limit now in force, and the one it replaced.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "sourceId": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "type": {
                      "type": "string",
                      "enum": [
                        "company"
                      ]
                    },
                    "username": {
                      "type": "string",
                      "description": "The source's stored handle, exactly as GET /api/v1/sources reports it."
                    },
                    "creditCapPerSync": {
                      "type": [
                        "integer",
                        "null"
                      ],
                      "description": "The limit now stored. `null` means no limit. Per sync, every sync \u2014 the capture cap counts lead rows, while billing charges once per new person per source, with no estimate, no confirmation gate and no team ceiling: the team's `dailyCeiling` counts KEYWORD spend only. Not a keyword search's `creditCap`, which is the daily bound on a recurring sweep and has all three."
                    },
                    "mode": {
                      "type": "string",
                      "enum": [
                        "engagers",
                        "posts_only"
                      ],
                      "description": "Present only when the request named `mode`: the mode now stored."
                    },
                    "postsPerSync": {
                      "type": [
                        "integer",
                        "null"
                      ],
                      "description": "Present only when the request named `mode`: the posts-only bound now stored, `null` in engagers mode."
                    },
                    "captureReplies": {
                      "type": "boolean",
                      "description": "Present only when the request named it: the reply-capture setting now stored."
                    },
                    "enrichLeads": {
                      "type": "boolean",
                      "description": "Present only when the request named it: the raw-mode setting now stored (false = raw)."
                    },
                    "previous": {
                      "type": "object",
                      "description": "What it was before this call, so a caller can report the change without having read the source first.",
                      "properties": {
                        "creditCapPerSync": {
                          "type": [
                            "integer",
                            "null"
                          ],
                          "description": "The limit this call replaced \u2014 `null` when the source had none. Per sync, every sync \u2014 the capture cap counts lead rows, while billing charges once per new person per source, with no estimate, no confirmation gate and no team ceiling: the team's `dailyCeiling` counts KEYWORD spend only. Not a keyword search's `creditCap`, which is the daily bound on a recurring sweep and has all three."
                        },
                        "mode": {
                          "type": "string",
                          "enum": [
                            "engagers",
                            "posts_only"
                          ],
                          "description": "The mode before this call. Present only when the request named `mode`."
                        },
                        "postsPerSync": {
                          "type": [
                            "integer",
                            "null"
                          ],
                          "description": "The posts-only bound before this call, `null` in engagers mode. Present only when the request named `mode`."
                        },
                        "captureReplies": {
                          "type": "boolean",
                          "description": "The reply-capture setting before this call. Always present."
                        },
                        "enrichLeads": {
                          "type": "boolean",
                          "description": "The raw-mode setting before this call (true = enriching). Always present."
                        }
                      }
                    }
                  }
                },
                "examples": {
                  "lowered": {
                    "value": {
                      "sourceId": "8f1c\u2026",
                      "type": "company",
                      "username": "demo-company",
                      "creditCapPerSync": 250,
                      "previous": {
                        "creditCapPerSync": 5000
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "No setting in the body (none of `creditCapPerSync`, `mode`, `captureReplies`, `enrichLeads`), an invalid `mode`, a `postsPerSync` outside 1-60 or sent without `mode: \"posts_only\"`, a value outside 1\u20132147483647, a non-boolean `captureReplies` or `enrichLeads`, an unknown field (`unknown_field`), or the renamed `creditCap` (`renamed_field`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/SubscriptionInactive"
          },
          "404": {
            "description": "No such tracked company page for this team, or it has been untracked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "409": {
            "description": "`spend_confirmation_required`: the body switches a posts-only source to the engagers sweep without `confirmSpend: true`. Nothing was written. The body carries `error`, `code`, `previous` ({ mode, postsPerSync }), the requested `mode` and the source's `creditCapPerSync`; say the cost to the person and re-send with `confirmSpend: true` only after a yes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "502": {
            "description": "A `mode` change could not read the source's current mode (or the write failed), so nothing was changed. Retry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      },
      "delete": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Untrack a company page",
        "operationId": "untrackCompany",
        "description": "Stop tracking a LinkedIn company page. Synchronous. SOFT DELETE, the same contract as untracking a profile, a post or a keyword search: the source is DEACTIVATED (`status: \"inactive\"`), never removed, and nothing it captured is destroyed \u2014 `leads.tracked_profile_id` is `NOT NULL ... ON DELETE CASCADE`, so a row delete would take the captured leads with it. ACCESS IS A SEPARATE QUESTION FROM STORAGE, and this endpoint answers both differently: nothing is erased, but the leads it captured STOP BEING SERVED \u2014 GET /api/v1/leads and GET /api/v1/engagers exclude them from the all-sources view and return `404` for its `profileId`/`username`, exactly as the dashboard, its stat cards and its CSV export do, and the source stops being actionable (POST /api/v1/company/{username}/push and its `/webhook` and `/icp` routes all `404`). `GET /api/v1/sources?includeInactive=true` enumerates what you used to track; that call lists the source and does not serve its leads. THE LEADS HAVE THEIR OWN OPT-IN: pass the same parameter to the endpoints that serve them \u2014 `GET /api/v1/leads?includeInactive=true` and `GET /api/v1/engagers?includeInactive=true` \u2014 and the kept leads are returned and this `username`/`profileId` resolves instead of 404ing. It is an API-only opt-in and the default is unchanged, so the dashboard, its stat cards and its CSV export still show nothing for this source. Reactivating does: re-tracking the same username revives the source and its leads are read again. A `404` here means out of scope for that read, never that anything was erased \u2014 erasure is a support request, not an API call. Keyword searches are the one kind whose leads stay readable after untracking; see DELETE /api/v1/keyword/{id}. The sweep stops, the source leaves GET /api/v1/sources unless you pass `?includeInactive=true`, and re-tracking the same username REVIVES it. Credits already spent are never refunded. Scoped to the authenticated team; a company page the team does not track returns `404`. Trial profiles cannot be deleted (`403`) \u2014 subscribe to a paid plan to manage profiles.\n\nWHAT HAPPENS TO WORK THAT IS ALREADY RUNNING \u2014 the half this used to leave unsaid, and the half that costs money. A SWEEP ALREADY RUNNING IS STOPPED: the run asks whether its source is still tracked at every checkpoint it passes (before each provider call, before each post is harvested, before each chunk of lead rows) and abandons the run at the first checkpoint after the delete. THAT IS \u201cAT THE NEXT CHECKPOINT\u201d, NOT \u201cINSTANTLY\u201d: a provider call already in flight finishes first, and the check is coalesced behind a short window (one second by default), so expect the stop within moments rather than at the instant the delete returns. If the status read itself fails the run CARRIES ON to the next checkpoint \u2014 the control channel fails open on purpose, because abandoning a paying customer's sweep over one timed-out SELECT is the worse error. LEADS ALREADY WRITTEN ARE KEPT, and the run is finalised as `completed` with `stoppedBy: \"untracked\"` on GET /api/v1/sources/{id}/sync (and the username-keyed /sync routes). \u26a0\ufe0f THAT VALUE IS NOT IN `lastRun.stoppedBy` on GET /api/v1/sources and never will be: that enum describes how a keyword SWEEP ended and is a closed set. QUEUED ENRICHMENT IS WRITTEN OFF AND NOT CHARGED: enrichment lands minutes to hours after capture, so the backlog is the part of an untracked source that would otherwise go on billing \u2014 every lead of this source still `pending` or `processing` is marked `skipped` with the reason `source_untracked` and costs no enriching credits. Re-tracking this source returns exactly those leads to `pending` at the start of its next sync, and returns no other skipped lead with it.",
        "parameters": [
          {
            "name": "username",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/LinkedInUsername"
            },
            "description": "Public identifier of the tracked company page (not a full URL), e.g. demo-company."
          }
        ],
        "responses": {
          "200": {
            "description": "Company page untracked and deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Untracked"
                },
                "examples": {
                  "untracked": {
                    "value": {
                      "ok": true,
                      "username": "demo-company",
                      "profileType": "company",
                      "untracked": true
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TrackedProfileDeleteForbidden"
          },
          "404": {
            "$ref": "#/components/responses/CompanyProfileNotTracked"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/v1/profile/posts": {
      "post": {
        "tags": [
          "Posts"
        ],
        "summary": "Create a profile posts job",
        "operationId": "createProfilePostsJob",
        "description": "Creates an asynchronous job to fetch posts for a tracked personal LinkedIn profile. Results are returned in pages of up to 15 posts (newest first): the completed job's `result` is `{ data: Post[], paginationToken?: string }`. Each post has `contentType`: VIDEO, IMAGE, JOB, LIVE_VIDEO, DOCUMENT or COLLABORATIVE_ARTICLE, the same vocabulary as the keyword search `contentType` filter; it is null when the provider gives no matching type. `paginationToken` is present only while more posts exist \u2014 pass it in the next request to page through older posts without overlap; its absence means the end of the reachable history. Reachable depth is bounded (roughly the most recent ~300 posts).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ProfilePostsRequest"
              },
              "examples": {
                "firstPage": {
                  "summary": "Fetch the first page",
                  "value": {
                    "username": "demo-profile"
                  }
                },
                "nextPage": {
                  "summary": "Fetch a later page",
                  "value": {
                    "username": "demo-profile",
                    "paginationToken": "page-token-demo"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Job accepted. Poll the status endpoint while the job is `pending` or `running`, until it reaches `completed` or `failed`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobAccepted"
                },
                "examples": {
                  "accepted": {
                    "value": {
                      "jobId": "00000000-0000-4000-8000-000000000003"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/MissingUsername"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/SubscriptionInactive"
          },
          "404": {
            "$ref": "#/components/responses/ProfileNotTracked"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/v1/company/posts": {
      "post": {
        "tags": [
          "Posts"
        ],
        "summary": "Create a company posts job",
        "operationId": "createCompanyPostsJob",
        "description": "Creates an asynchronous job to fetch posts for a tracked LinkedIn company page. Results are returned in pages of up to 15 posts (newest first): the completed job's `result` is `{ data: Post[], paginationToken?: string }`. Each post has `contentType`: VIDEO, IMAGE, JOB, LIVE_VIDEO, DOCUMENT or COLLABORATIVE_ARTICLE, the same vocabulary as the keyword search `contentType` filter; it is null when the provider gives no matching type. `paginationToken` is present only while more posts exist \u2014 pass it in the next request to page through older posts without overlap; its absence means the end of the reachable history. Reachable depth is bounded (roughly the most recent ~300 posts).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CompanyPostsRequest"
              },
              "examples": {
                "firstPage": {
                  "summary": "Fetch the first page",
                  "value": {
                    "username": "demo-company"
                  }
                },
                "nextPage": {
                  "summary": "Fetch a later page",
                  "value": {
                    "username": "demo-company",
                    "paginationToken": "page-token-demo"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Job accepted. Poll the status endpoint while the job is `pending` or `running`, until it reaches `completed` or `failed`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobAccepted"
                },
                "examples": {
                  "accepted": {
                    "value": {
                      "jobId": "00000000-0000-4000-8000-000000000004"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/MissingUsername"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/SubscriptionInactive"
          },
          "404": {
            "$ref": "#/components/responses/CompanyProfileNotTracked"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/v1/post/reactions": {
      "post": {
        "tags": [
          "Engagements"
        ],
        "summary": "Create a post reactions job",
        "operationId": "createPostReactionsJob",
        "description": "Fetches reactions for a post this team holds - a tracked post, or one its keyword searches or posts-only watches found. WHICH POSTS - `postUrn` is accepted when this team holds the post: (a) one of the 15 most recent posts of a tracked person, company page or tracked post, or (b) a post one of its keyword searches harvested - every `urn` GET /api/v1/sources/{id}/kept-posts returns with a non-null `url`, a stopped search's included - or one of its posts-only watches saw (GET /api/v1/sources/{id}/posts). COST - the call itself charges nothing. On a tracked post (a) the engagers are also saved as that source's leads - the people its own sync captures - so a person new to that source is enriched and billed 1 credit afterwards, like any of its leads. A keyword-search or posts-only post (b) is only READ: the same rows come back and none is saved, so the pull charges nothing then or later and touches neither the search's `creditCap` nor the team's `dailyCeiling`. REFUSALS - 404 `Post not found` when the team holds no such post; a post only another team holds answers exactly the same. 404 with an error starting `Post not tracked` when the team holds it only as an older post of a tracked profile or company page (outside its 15 most recent) or under an untracked posts-only watch: track the post itself with POST /api/v1/post/track (which captures and charges like any tracked source) and retry once its first sync has finished. The endpoint creates a job in `running`, processes it immediately in the team's queue, and returns a `jobId` for a job that is already `completed` or `failed`. GRAIN and PAGING - `total` is the provider's DECLARED count of reactions on the post: not a row count, not a completeness check. IT CAN EXCEED THE ROWS THE PROVIDER WILL SERVE, never the reverse - measured, a post declaring 1167 delivered 1116 rows over a full 24-page sweep, about 5% short, and that gap is OBSERVED rather than a guaranteed bound. It also drifts within one sweep (1165 on pages 0-2, then 1167), so never cache page 0's value and compare later pages against it. THE VERDICT: a sweep that ends with `hasMore: false` has collected everything available from this endpoint, even when the rows fall short of `total` - your loop is not broken; report the shortfall rather than retrying it. LARGE POSTS HAVE A PROVIDER CEILING OF ABOUT 1,100 PEOPLE. Measured 29 September 2026 on a post declaring 3,490 reactions: asked of the primary, page 0 was answered by the fallback provider and every later page was empty; the fallback then served 22 pages of about 50 \u2014 1,093 unique identified reactors \u2014 and an empty page 22, with `total` still 3,490 on every response. That last page reports `hasMore: false` and `exhausted: false`: the sweep is over and the rest will not be served, by either provider, on any retry. A tracked post's sync reports the same wall as `capture.stoppedBy: \"provider_limit\"` with `capture.coverage` (GET /api/v1/sources/{id}/sync), under the same one-page rule `exhausted` uses here, so the two surfaces agree about the same post. `hasMore` is the termination signal and the only field to branch on; it errs toward `true` (wrong on 7 of 36 measured transitions, always on what turned out to be the last page), so a sweep is never truncated and at worst costs one extra request, and an empty `data` always ends it. Dedupe on `entityUrn`: the provider can repeat a reactor across two pages when its count shifts mid-sweep (2 of those 1116 rows), so a deduped count falls further below `total` still. THAT REPETITION IS DRIFT-SCALE - a couple of rows per thousand, around the boundary the count moved across - so dedupe and move on. A WHOLE PAGE of repeats is a different thing: a page that comes back as an exact copy of the one before it is a paging defect, not drift (one was measured on 2026-09-09 at 332 rows for 282 reactors - exactly one duplicated page - and fixed), so report that rather than deduping around it. `total` REACHES YOU ON EVERY PAGE, including one a fallback provider served with no count of its own (7 of 42 measured results): the post's own stored reaction count fills in, so the field is never absent on the page that carries your rows. It is `null` - present, never omitted - only when no number exists anywhere, meaning no provider count and no stored count. `hasMore` still falls back to page-fullness on a page the provider gave no count for, the rule the comment endpoints use. ECHO `source` BACK OR YOUR SWEEP STOPS AFTER ONE PAGE. Every page reports `source` - \"primary\" or \"fallback\" - naming the provider that served it, and it is the only paging state besides `page`. Send it back unchanged as `source` on every request after the first of the same sweep. When the primary has nothing for a post a fallback answers `page: 0`; a `page: 1` request that does not carry `source` is asked of the primary again, gets the same nothing, and reads as the end of pagination. That is the FALLBACK-SERVED SHORTFALL, measured 2026-09-08 at 50 rows of a declared 278 (18% reachable) and 49 of 574 (9%), and on the same post one day earlier at 50 of 104, 52% unreachable - the gap is capped by ONE PAGE regardless of the post, so it grows with the post rather than staying a fixed share. THE SIGNATURE OF HAVING OMITTED IT: rows on page 0 with `source: \"fallback\"`, an empty page 1, and `exhausted: false`. It is stable across retries, so re-running the sweep is not the remedy; carrying `source` is, and the sweep then pages to exhaustion. Omitting `source` is never an error, only a truncation, and it costs nothing on a primary-served post. THE SERVER NOW DEFAULTS IT, AS A SAFETY NET FOR CALLERS THAT CANNOT SEND IT: `source` omitted on a page past the first is taken to be whichever provider served `page: 0` of the SAME post, when that was within the last hour. An explicit `source` always wins over that default; `page: 0` is always decided afresh, so a post whose provider changes hands is picked up by the next sweep rather than remembered; and a sweep that STARTS past page 0, or pauses more than an hour between pages, has nothing to default from and gets the one-page behaviour above. SEND IT ANYWAY - it is the only thing that makes a sweep correct whatever its timing. `hasMore: false` DOES NOT MEAN YOU HAVE THEM ALL - READ `exhausted`. `hasMore` remains the termination signal and the only field to branch on for whether to request again. `exhausted` answers the other question: `true` means the provider ran out of rows, `false` means it stopped with a page's worth or more still declared and those rows are not reachable by paging, so report the shortfall rather than retrying it. A sweep that merely drifts under `total` (the ~5% above) keeps `exhausted: true` - the line is one full page, the unit the provider serves in. While `hasMore` is true, `exhausted` is `false` and means only \"not yet\". `total` is never `0` past the end: the provider sends `totalElements: 0` there and Cornersight reports the post's own stored reaction count instead, so a `0` means the post really has no reactions and reaches you only on `page: 0`. PAGE SIZE IS FIXED AT 50 and is not a request parameter: `page` is the only paging control, and a `size` in the body is ignored rather than rejected. So the \"last page comes back exactly full\" case is not something you can provoke - it happens only when a post's count is an exact multiple of 50, which was true of 14 of 1,000 measured posts. CONTRAST /api/v1/post/comments: its `total` counts something different (top-level comments only) and its `hasMore` is coarser - the same field name does NOT mean the same thing on the two endpoints. IDENTITY MAY BE NULL. Some engagements come back from the data provider carrying the engagement and no identifiable person - a reaction with no reactor, a comment with an empty author. Measured across all stored results on 2026-08-30: about 1 in 116 reactions and 1 in 300 comments. Those rows are returned with every identity field present and set to `null` (never omitted), so every row in `data` has the same shape. THEY ARE COUNTED BUT NOT USABLE AS LEADS: `total` and `hasMore` include them, and Cornersight's own capture skips them, so do NOT read `total` as a count of contactable people. Filter on a null `username` to get the usable subset.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PostReactionsRequest"
              },
              "examples": {
                "firstPage": {
                  "summary": "Fetch the first page",
                  "value": {
                    "postUrn": "urn:li:activity:0000000000000000000",
                    "page": 0
                  }
                },
                "nextPage": {
                  "summary": "Fetch a later page, carrying the source the previous page reported",
                  "value": {
                    "postUrn": "urn:li:activity:0000000000000000000",
                    "page": 1,
                    "source": "fallback"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Job processed immediately. Read the status endpoint for the completed or failed result.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobAccepted"
                },
                "examples": {
                  "accepted": {
                    "value": {
                      "jobId": "00000000-0000-4000-8000-000000000005"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/MissingPostUrn"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/SubscriptionInactive"
          },
          "404": {
            "$ref": "#/components/responses/PostNotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/v1/post/comments": {
      "post": {
        "tags": [
          "Engagements"
        ],
        "summary": "Create a personal post comments job",
        "operationId": "createPostCommentsJob",
        "description": "Fetches comments for a personal-profile post this team holds - a tracked post, or one its keyword searches or posts-only watches found. WHICH POSTS - `postUrn` is accepted when this team holds the post: (a) one of the 15 most recent posts of a tracked person, company page or tracked post, or (b) a post one of its keyword searches harvested - every `urn` GET /api/v1/sources/{id}/kept-posts returns with a non-null `url`, a stopped search's included - or one of its posts-only watches saw (GET /api/v1/sources/{id}/posts). COST - the call itself charges nothing. On a tracked post (a) the engagers are also saved as that source's leads - the people its own sync captures - so a person new to that source is enriched and billed 1 credit afterwards, like any of its leads. A keyword-search or posts-only post (b) is only READ: the same rows come back and none is saved, so the pull charges nothing then or later and touches neither the search's `creditCap` nor the team's `dailyCeiling`. REFUSALS - 404 `Post not found` when the team holds no such post; a post only another team holds answers exactly the same. 404 with an error starting `Post not tracked` when the team holds it only as an older post of a tracked profile or company page (outside its 15 most recent) or under an untracked posts-only watch: track the post itself with POST /api/v1/post/track (which captures and charges like any tracked source) and retry once its first sync has finished. The endpoint creates a job in `running`, processes it immediately in the team's queue, and returns a `jobId` for a job that is already `completed` or `failed`. CHOOSE EITHER - the personal and company comment endpoints accept ANY post URN regardless of author type, and neither checks it. They take an IDENTICAL primary path: the same provider call, same request body, same page size, and no sort parameter, so ordering is the provider's default for both. They diverge only when that call FAILS, and each then falls back to a DIFFERENT upstream endpoint and skips the reply-augmentation step, so a fallback result can carry fewer replies. An HTTP 500 always diverts. Anything else depends on how the deployment is configured: with ENGAGER_TRANSIENT_FALLBACK_ENABLED=true (off by default) a 429, 502, 503 or 504 that outlives its retries, and a connection-level or timeout failure, divert as well; with the flag off those are retried and then surfaced as errors. Nothing enforces the personal/company distinction, and enforcing it would cost an extra provider call for the ~11% of posts whose author type we cannot infer. GRAIN AND PAGING - `total` counts TOP-LEVEL comments only. `size` accepts 1–50 top-level comments per page (default 50); `includeReplies` defaults to true, and false returns top-level comments only with `isReply: false`. When replies are included, `data` can be larger than `total`. Do not page against `total`; `hasMore` is true whenever `data` is non-empty, so a full sweep has one final empty page. CONTRAST /api/v1/post/reactions, where `total` is the post's DECLARED reactor count - it can exceed the rows served, so it is not a completeness check there either - and `hasMore` is count-based rather than exact. IDENTITY MAY BE NULL. Some engagements come back from the data provider carrying the engagement and no identifiable person - a reaction with no reactor, a comment with an empty author. Measured across all stored results on 2026-08-30: about 1 in 116 reactions and 1 in 300 comments. Those rows are returned with every identity field present and set to `null` (never omitted), so every row in `data` has the same shape. THEY ARE COUNTED BUT NOT USABLE AS LEADS: `total` and `hasMore` include them, and Cornersight's own capture skips them, so do NOT read `total` as a count of contactable people. Filter on a null `username` to get the usable subset. COMMENT TIME - every row carries `postedAt` (ISO 8601) and `postedAtTimestamp` (epoch milliseconds), the same names and types as a post row, and both are `null` - present, never omitted - only when no comment time can be obtained. HOW THE TIME IS OBTAINED - the provider's own comment time when it sends one (the fallback provider does, and it always wins); otherwise the time the comment's own ID encodes. A LinkedIn comment ID carries its creation time: in `urn:li:comment:(activity:<postId>,<commentId>)`, `commentId` shifted right by 22 bits is the creation time in epoch milliseconds, which reproduced the stated creation time of every published example checked to within 2 ms. That is how rows from the primary provider, whose comment object has no time field, carry one. A decoded time is used only when plausible - after 2003-05-01, not in the future and not before the post - and is otherwise `null`. Neither is ever filled from the post's time. On a lead captured from a comment the same time is `commentPostedAt`, while `postPostedAt` is the POST's time. ROW SHAPE - every row has the same fields whichever provider served the page, as the PostComment schema documents: `id` (the comment's URN - its stable key; dedupe on it), `url` (opens the comment on LinkedIn), `text`, `postedAt`, `postedAtTimestamp`, `isReply`, `parentCommentUrn` (a reply's parent comment, otherwise `null`), `isEdited`, `isPinned`, `totalReactions`, `totalComments` (replies to this comment), `reactionType` and `author` - `type` (`person`, `company`, or `null` when the provider names the commenter only by display name), `username`, `profileUrl`, `url`, `linkedinUrl`, `name`, `firstName`, `lastName`, `headline`, `profilePicture` and `entityUrn`. Unknown values are `null`, never omitted. A company commenting as itself has `author.type` `company`, no `username`, a linkedin.com/company/ URL and its company URN, and is never captured as a lead. `comment` and `commenter` are deprecated aliases of `text` and `author`, kept for existing callers. The completed job's `result` is PostCommentsResult: `data`, `total`, `hasMore` and `source` - `primary` or `fallback`, the provider that served the page, as on reactions. On comments `source` is a report only: there is no `source` request field, and each page is served by whichever provider answers it. REPLY SHARE - replies are typically a minority of rows: in a production measurement of stored comment results, 22% of the rows beyond each result's first 10 were replies and 78% were top-level comments. A given post can differ widely, so filter on `isReply` rather than assuming a ratio.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PostCommentsRequest"
              },
              "examples": {
                "firstPage": {
                  "summary": "Fetch the first page",
                  "value": {
                    "postUrn": "urn:li:activity:0000000000000000000",
                    "page": 0
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Job processed immediately. Read the status endpoint for the completed or failed result.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobAccepted"
                },
                "examples": {
                  "accepted": {
                    "value": {
                      "jobId": "00000000-0000-4000-8000-000000000006"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/MissingPostUrn"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/SubscriptionInactive"
          },
          "404": {
            "$ref": "#/components/responses/PostNotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/v1/post/company-comments": {
      "post": {
        "tags": [
          "Engagements"
        ],
        "summary": "Create a company post comments job",
        "operationId": "createCompanyPostCommentsJob",
        "description": "Fetches comments for a company-page post this team holds - a tracked post, or one its keyword searches or posts-only watches found. WHICH POSTS - `postUrn` is accepted when this team holds the post: (a) one of the 15 most recent posts of a tracked person, company page or tracked post, or (b) a post one of its keyword searches harvested - every `urn` GET /api/v1/sources/{id}/kept-posts returns with a non-null `url`, a stopped search's included - or one of its posts-only watches saw (GET /api/v1/sources/{id}/posts). COST - the call itself charges nothing. On a tracked post (a) the engagers are also saved as that source's leads - the people its own sync captures - so a person new to that source is enriched and billed 1 credit afterwards, like any of its leads. A keyword-search or posts-only post (b) is only READ: the same rows come back and none is saved, so the pull charges nothing then or later and touches neither the search's `creditCap` nor the team's `dailyCeiling`. REFUSALS - 404 `Post not found` when the team holds no such post; a post only another team holds answers exactly the same. 404 with an error starting `Post not tracked` when the team holds it only as an older post of a tracked profile or company page (outside its 15 most recent) or under an untracked posts-only watch: track the post itself with POST /api/v1/post/track (which captures and charges like any tracked source) and retry once its first sync has finished. The endpoint creates a job in `running`, processes it immediately in the team's queue, and returns a `jobId` for a job that is already `completed` or `failed`. CHOOSE EITHER - the personal and company comment endpoints accept ANY post URN regardless of author type, and neither checks it. They take an IDENTICAL primary path: the same provider call, same request body, same page size, and no sort parameter, so ordering is the provider's default for both. They diverge only when that call FAILS, and each then falls back to a DIFFERENT upstream endpoint and skips the reply-augmentation step, so a fallback result can carry fewer replies. An HTTP 500 always diverts. Anything else depends on how the deployment is configured: with ENGAGER_TRANSIENT_FALLBACK_ENABLED=true (off by default) a 429, 502, 503 or 504 that outlives its retries, and a connection-level or timeout failure, divert as well; with the flag off those are retried and then surfaced as errors. Nothing enforces the personal/company distinction, and enforcing it would cost an extra provider call for the ~11% of posts whose author type we cannot infer. GRAIN AND PAGING - `total` counts TOP-LEVEL comments only. `size` accepts 1–50 top-level comments per page (default 50); `includeReplies` defaults to true, and false returns top-level comments only with `isReply: false`. When replies are included, `data` can be larger than `total`. Do not page against `total`; `hasMore` is true whenever `data` is non-empty, so a full sweep has one final empty page. CONTRAST /api/v1/post/reactions, where `total` is the post's DECLARED reactor count - it can exceed the rows served, so it is not a completeness check there either - and `hasMore` is count-based rather than exact. IDENTITY MAY BE NULL. Some engagements come back from the data provider carrying the engagement and no identifiable person - a reaction with no reactor, a comment with an empty author. Measured across all stored results on 2026-08-30: about 1 in 116 reactions and 1 in 300 comments. Those rows are returned with every identity field present and set to `null` (never omitted), so every row in `data` has the same shape. THEY ARE COUNTED BUT NOT USABLE AS LEADS: `total` and `hasMore` include them, and Cornersight's own capture skips them, so do NOT read `total` as a count of contactable people. Filter on a null `username` to get the usable subset. COMMENT TIME - every row carries `postedAt` (ISO 8601) and `postedAtTimestamp` (epoch milliseconds), the same names and types as a post row, and both are `null` - present, never omitted - only when no comment time can be obtained. HOW THE TIME IS OBTAINED - the provider's own comment time when it sends one (the fallback provider does, and it always wins); otherwise the time the comment's own ID encodes. A LinkedIn comment ID carries its creation time: in `urn:li:comment:(activity:<postId>,<commentId>)`, `commentId` shifted right by 22 bits is the creation time in epoch milliseconds, which reproduced the stated creation time of every published example checked to within 2 ms. That is how rows from the primary provider, whose comment object has no time field, carry one. A decoded time is used only when plausible - after 2003-05-01, not in the future and not before the post - and is otherwise `null`. Neither is ever filled from the post's time. On a lead captured from a comment the same time is `commentPostedAt`, while `postPostedAt` is the POST's time. ROW SHAPE - every row has the same fields whichever provider served the page, as the PostComment schema documents: `id` (the comment's URN - its stable key; dedupe on it), `url` (opens the comment on LinkedIn), `text`, `postedAt`, `postedAtTimestamp`, `isReply`, `parentCommentUrn` (a reply's parent comment, otherwise `null`), `isEdited`, `isPinned`, `totalReactions`, `totalComments` (replies to this comment), `reactionType` and `author` - `type` (`person`, `company`, or `null` when the provider names the commenter only by display name), `username`, `profileUrl`, `url`, `linkedinUrl`, `name`, `firstName`, `lastName`, `headline`, `profilePicture` and `entityUrn`. Unknown values are `null`, never omitted. A company commenting as itself has `author.type` `company`, no `username`, a linkedin.com/company/ URL and its company URN, and is never captured as a lead. `comment` and `commenter` are deprecated aliases of `text` and `author`, kept for existing callers. The completed job's `result` is PostCommentsResult: `data`, `total`, `hasMore` and `source` - `primary` or `fallback`, the provider that served the page, as on reactions. On comments `source` is a report only: there is no `source` request field, and each page is served by whichever provider answers it. REPLY SHARE - replies are typically a minority of rows: in a production measurement of stored comment results, 22% of the rows beyond each result's first 10 were replies and 78% were top-level comments. A given post can differ widely, so filter on `isReply` rather than assuming a ratio.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PostCommentsRequest"
              },
              "examples": {
                "firstPage": {
                  "summary": "Fetch the first page",
                  "value": {
                    "postUrn": "urn:li:activity:0000000000000000000",
                    "page": 0
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Job processed immediately. Read the status endpoint for the completed or failed result.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobAccepted"
                },
                "examples": {
                  "accepted": {
                    "value": {
                      "jobId": "00000000-0000-4000-8000-000000000007"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/MissingPostUrn"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/SubscriptionInactive"
          },
          "404": {
            "$ref": "#/components/responses/PostNotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/v1/post/track": {
      "post": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Track a LinkedIn post",
        "operationId": "trackPost",
        "description": "Creates a tracked POST source for the authenticated team and queues its first engagement capture. Synchronous, unlike the engagement job endpoints: the permalink is resolved against LinkedIn before anything is stored, so the call returns either a verified source or an error, never a source that cannot capture.\n\nTracked posts have their own trial ceiling, separate from tracked profiles \u2014 a trial team may hold both. Re-tracking a post the team already has never creates a second source: an active source keeps its URL and applies any named captureReplies or creditCapPerSync setting without another capture, while an untracked source is reactivated \u2014 which queues a fresh capture and therefore charges. The status is `201` either way. A re-track of a post that has ALREADY SYNCED queues nothing and says so \u2014 `syncId: null` beside `syncNotQueuedReason` (POST /api/v1/sources/{id}/sync syncs it now) \u2014 and otherwise the body does not distinguish a duplicate from a genuine create, so re-read GET /api/v1/sources if you need to know which happened. (POST /api/v1/keyword/track, whose identity is its keywords rather than a URN, answers the same case with `200` and `resumed: true`.)\n\nWHAT YOU CAN DO WITH A TRACKED POST FROM THIS API: it is listed by GET /api/v1/sources (filter ?type=post), its engagers are returned by GET /api/v1/leads like any other source's, and it is removed by DELETE /api/v1/post/{urn}.\n\nITS SYNC STATUS, WEBHOOK AND ICP CONFIG ARE AT /api/v1/sources/{id}/sync, /webhook and /icp \u2014 addressed by the SOURCE ID this response's source carries and GET /api/v1/sources lists, not by the URN. Use the `syncId` above with GET /api/v1/sources/{id}/sync (the source id, not the syncId) to follow the capture you just queued. The per-kind routes cannot take a post: /api/v1/{profile|company}/{username}/sync, /webhook and /icp are keyed by a LinkedIn username and a post's identifier is an activity URN, which is why these were dashboard-only until the source-id family existed. Nothing about the post source was ever the limitation \u2014 it syncs, delivers to a webhook and is ICP-filtered exactly like any other source.\n\nLARGE POSTS STOP AT THE PROVIDER'S CEILING, ABOUT 1,100 PEOPLE. Measured 29 September 2026 on a public company post declaring 3,490 reactions: the data provider served about 1,100 reactors (22 pages of ~50) and then only empty pages, while every page still declared 3,490. No paging, retry or `creditCapPerSync` gets the rest \u2014 it is not obtainable from the provider. Its sync says so instead of claiming it collected everything: GET /api/v1/sources/{id}/sync ends with `capture.stoppedBy: \"provider_limit\"` (and the same at the top level), and `capture.coverage` gives the declared and captured counts side by side. A post under the ceiling captures in full and still ends `exhausted`.\n\n`400` means the URL can never work and the message says what to paste instead. `503` with code `post_resolve_failed` means the post could not be verified right now and the request is worth retrying; no source is created in either case.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TrackPostRequest"
              },
              "examples": {
                "permalink": {
                  "summary": "Track a post by its permalink",
                  "value": {
                    "postUrl": "https://www.linkedin.com/posts/some-person_a-post-slug-activity-0000000000000000000-AbCd"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The post is now tracked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TrackPostResponse"
                }
              }
            }
          },
          "400": {
            "description": "`postUrl` is missing, is not a LinkedIn post URL, or is a form that cannot be resolved (a bare `urn:li:share:` URN)."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TrackedProfileDeleteForbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          },
          "503": {
            "description": "The post could not be verified against LinkedIn right now. Retryable; no source was created. Body carries `code: post_resolve_failed`."
          }
        }
      }
    },
    "/api/v1/post/{urn}": {
      "delete": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Untrack a LinkedIn post",
        "operationId": "untrackPost",
        "description": "Stop tracking a post and stop capturing its engagement. The delete half of the pair with POST /api/v1/post/track. Synchronous.\n\nSOFT DELETE, like untracking a profile: the source is deactivated, never removed, because leads reference it and a row delete would cascade the captured leads away with it. The post stops syncing and leaves the tracked-source list. Its captured leads are RETAINED in your account but stop being SERVED: GET /api/v1/leads excludes an untracked post's leads from the all-sources view and returns 404 for its id BY DEFAULT, which is what the dashboard shows too. `GET /api/v1/sources?includeInactive=true` still lists the post, with `status: \"inactive\"` \u2014 and `GET /api/v1/leads?includeInactive=true` / `GET /api/v1/engagers?includeInactive=true` read the kept leads back and make that id resolve. That opt-in exists on the API only; the dashboard has no equivalent switch, so its own view is unchanged. Keyword searches are the one kind whose leads stay readable after untracking \u2014 see DELETE /api/v1/keyword/{id}. Untracking a post that is already untracked returns `404`.\n\nIdentify the post by the URN that GET /api/v1/sources returns for it (`urn:li:activity:\u2026` or `urn:li:ugcPost:\u2026`) \u2014 the same value POST /api/v1/post/track echoes back as `postUrn`. The colons are legal in a path segment, so both the raw and percent-encoded forms work.\n\nA tracked post's sync status, webhook config and ICP config are reached by its SOURCE ID at /api/v1/sources/{id}/sync, /webhook and /icp \u2014 not by this URN; see POST /api/v1/post/track.\n\n`403` for a post tracked during a free trial: trial sources cannot be deleted on any surface, the dashboard included, until the team subscribes.\n\nWHAT HAPPENS TO WORK THAT IS ALREADY RUNNING \u2014 the half this used to leave unsaid, and the half that costs money. A SWEEP ALREADY RUNNING IS STOPPED: the run asks whether its source is still tracked at every checkpoint it passes (before each provider call, before each post is harvested, before each chunk of lead rows) and abandons the run at the first checkpoint after the delete. THAT IS \u201cAT THE NEXT CHECKPOINT\u201d, NOT \u201cINSTANTLY\u201d: a provider call already in flight finishes first, and the check is coalesced behind a short window (one second by default), so expect the stop within moments rather than at the instant the delete returns. If the status read itself fails the run CARRIES ON to the next checkpoint \u2014 the control channel fails open on purpose, because abandoning a paying customer's sweep over one timed-out SELECT is the worse error. LEADS ALREADY WRITTEN ARE KEPT, and the run is finalised as `completed` with `stoppedBy: \"untracked\"` on GET /api/v1/sources/{id}/sync (and the username-keyed /sync routes). \u26a0\ufe0f THAT VALUE IS NOT IN `lastRun.stoppedBy` on GET /api/v1/sources and never will be: that enum describes how a keyword SWEEP ended and is a closed set. QUEUED ENRICHMENT IS WRITTEN OFF AND NOT CHARGED: enrichment lands minutes to hours after capture, so the backlog is the part of an untracked source that would otherwise go on billing \u2014 every lead of this source still `pending` or `processing` is marked `skipped` with the reason `source_untracked` and costs no enriching credits. Re-tracking this source returns exactly those leads to `pending` at the start of its next sync, and returns no other skipped lead with it.",
        "parameters": [
          {
            "name": "urn",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "URN of the tracked post, e.g. urn:li:activity:7123456789012345678. Percent-encoding is optional."
          }
        ],
        "responses": {
          "200": {
            "description": "Post untracked. The source is deactivated; its captured leads are kept.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Untracked"
                },
                "examples": {
                  "untracked": {
                    "value": {
                      "ok": true,
                      "username": "urn:li:activity:7123456789012345678",
                      "profileType": "post",
                      "untracked": true
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The path segment is not a post URN."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TrackedProfileDeleteForbidden"
          },
          "404": {
            "description": "No active tracked post with that URN for this team."
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/v1/keyword/estimate": {
      "post": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Estimate a keyword search (dry run)",
        "operationId": "estimateKeyword",
        "description": "PRICES A KEYWORD SEARCH WITHOUT CREATING IT \u2014 the call to make BEFORE POST /api/v1/keyword/track.\n\nWHY IT EXISTS. The 409 `spend_confirmation_required` already carries the estimate, and it is a workable way to obtain one \u2014 but it makes \"show me the cost\" AN ERROR PATH. A CLI has to print a refusal in order to answer a question, and an agent written the ordinary way treats every non-2xx as a failure and never reads the body: the MCP layer collapses a failed tool result to `{ statusCode, error }`, so the numbers in that 409 reach no agent at all. This answers 200 with the same object, so they do. The 409 is unchanged and remains the ENFORCEMENT; this is the intended first call.\n\nIT CREATES NOTHING, RESUMES NOTHING AND CHARGES NOTHING. No source row, no search row, no status change on a search you already have, no queued sweep, no credit. It performs two reads and returns.\n\nTHE SAME BODY AS THE CREATE, WITH THE SAME 400s. Every field POST /api/v1/keyword/track accepts is accepted here and validated by the same code in the same order, so a body this endpoint prices is a body that endpoint would accept. An estimate that accepted what the create refuses would be answering a question about a search that cannot exist.\n\nTHE CONTRACT'S TWO GATES DO NOT APPLY HERE, deliberately. `scope_required` and `spend_confirmation_required` are about authorising a recurring charge, and this endpoint creates none \u2014 so the four scope fields are OPTIONAL here even after the cut-over, and `confirmSpend` is accepted (validated for shape, as everywhere) and does nothing. Requiring them would mean choosing the scope in order to be told what the scope costs, which is the question. The caps are still RESOLVED exactly as the create resolves them \u2014 sent wins, omitted inherits from the live search, else the create-time default \u2014 which is what makes this `estimatedDailyMax` and the subsequent 201's the same number.\n\nRESUMED. `resumed: true` means these terms already name a search this team has (ACTIVE or one you DELETED), so the create that follows would return THAT search, apply the settings you named and leave the rest alone \u2014 and, if it had been deleted, track it again and queue a charging sweep within seconds. `previous` then carries that search's CURRENT caps beside the requested ones, under the same key and with the same meaning the 409 gives it.\n\nTHE FORMULA behind `estimatedDailyMax` and `daysToExhaustAtCap` is written out once, on POST /api/v1/keyword/track. Both keys are OMITTED here, never null and never 0, when there is no honest number \u2014 test for the KEY.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TrackKeywordRequest"
              },
              "examples": {
                "minimal": {
                  "summary": "What would this cost at the defaults?",
                  "value": {
                    "keywords": [
                      "AI agents"
                    ]
                  }
                },
                "priced": {
                  "summary": "The scope you are considering, priced before you commit to it",
                  "value": {
                    "keywords": [
                      "AI agents",
                      "LLM evals"
                    ],
                    "datePosted": "PAST_WEEK",
                    "captureMode": "breadth",
                    "creditCap": 500
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The estimate. NOTHING WAS CREATED, RESUMED OR CHARGED. `remainingBalance`, `estimatedDailyMax` and `daysToExhaustAtCap` are OMITTED rather than nulled when there is no honest number, and `previous` is present only on a resume \u2014 test for the KEY. `previous` IS THE SEARCH'S WHOLE CURRENT SETTINGS, not only its four caps \u2014 those four keys are unchanged in name and meaning, and every other setting a resume could overwrite now sits beside them, which is the half that was being overwritten silently. `changed` lists what POST /api/v1/keyword/track would REWRITE on that search, each entry `{ field, from, to }`, and `settings` is what it would become; both are OMITTED entirely when nothing would change, so test for the KEY. When `changed` is present, the same body sent to the create is a `409` `settings_conflict` unless it carries `confirmChanges` \u2014 `confirmSpend` authorises the charge only and does not acknowledge a rewrite.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/KeywordEstimateResponse"
                }
              }
            }
          },
          "400": {
            "description": "The same 400s POST /api/v1/keyword/track returns for the same body, in the same order and with the same messages: `keywords` missing, empty, longer than ten terms, duplicated or over-long; `aiProvider`/`aiPrompt` not supplied together; code `invalid_enum_value` for `sort`, `datePosted`, `contentType`, `captureMode` or `aiProvider`; code `invalid_number` for `creditCap` or `maxEngagementsPerPost` outside its declared range; code `invalid_contract_version` for an unknown `contractVersion`. NOT returned for a missing scope field \u2014 `scope_required` is the create's gate and does not apply to a call that creates nothing. Also returned with code `invalid_urn` when a value in `authorIndustry`, `authorCompany`, `fromPerson`, `fromCompany`, `mentionsPerson` or `mentionsCompany` is not an id of the right shape. The body carries `field`, `value`, `expected` and `example` beside the message, so the fix needs no sentence parsing. A HANDLE IS NOT A URN: `fromPerson: [\"jasonlemkin\"]` used to be accepted and then failed at the provider on EVERY daily run, reported as a retryable outage. Resolve a handle for free with `GET /api/v1/profile/{username}/urn`. `authorKeyword` is plain words and is NOT checked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          },
          "502": {
            "description": "The current keyword search could not be verified because a database read failed. No estimate is returned. Nothing is created, resumed, queued, changed or charged; retry when the database is available.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/keyword/track": {
      "post": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Track a keyword search",
        "operationId": "trackKeyword",
        "description": "Creates a KEYWORD SEARCH: a recurring sweep that finds LinkedIn posts by ONE OR MORE keywords (up to ten), optionally filters them with your own AI model against a criterion you write, and captures the engagers of the survivors as leads — or, with `capturePostAuthors: true`, the person who WROTE each surviving post as an `Author` lead, beside the engagers or (`captureEngagers: false`) instead of them. An author is charged like an engager: one credit per NEW person for the search, repeats free, company-page authors skipped and never charged. It becomes a tracked source like a profile or a post \u2014 listed by GET /api/v1/sources with type `keyword`, its leads returned by GET /api/v1/leads.\n\nSYNCHRONOUS, and unlike POST /api/v1/post/track there is nothing to verify: a post must be resolved against LinkedIn before it can be stored, but a keyword search performs no provider call at creation \u2014 the sweep happens later. This returns the created source, not a jobId. The first sweep is queued immediately; poll its progress with GET /api/v1/{profile|company}/{username}/sync's sibling on the dashboard, or simply read the leads as they arrive.\n\nTHE AI KEY IS NOT ACCEPTED BY THIS ENDPOINT and cannot be supplied through this API at all. Save it once in the dashboard's AI filtering panel, on Keyword Engagement \u2014 the same panel that sets the provider and prompt. It is held in an encrypted vault, one key per provider per team, and never returned by any endpoint; the panel lists which providers your team has a key for so you can confirm one is stored. `aiProvider` only names WHICH stored key to use. A search whose provider has no saved key fails at run time with a clear reason rather than at creation. THE AI FILTER RUNS ON YOUR KEY AND IS BILLED BY YOUR PROVIDER, NOT BY CORNERSIGHT, and nothing you set here caps it except the run itself: every post a run scans is sent to your model \u2014 up to 2,000 posts in each run, 10 to a call (about 200 calls), or one call per post when the model's batch answer cannot be read \u2014 and a post already judged under the same prompt is never sent again. `creditCap` bounds Cornersight credits only.\n\nEACH TERM IS ONE PROVIDER CALL per run, merged and deduplicated by post URN. The run's scan \u2014 at most 2,000 posts, an internal safety bound and not a setting \u2014 is shared across the terms and allocated round-robin, so one high-volume term cannot crowd out the others, and a term that exhausts early yields its share.\n\nKeyword searches have their own trial ceiling, separate from profiles and posts.\n\nRemove one with DELETE /api/v1/keyword/{id}.\n\n\u26a0\ufe0f THE CUT-OVER IS SCHEDULED FOR 2026-11-01, AND THIS IS THE NOTICE OF IT. UNTIL 2026-11-01 an old-style create \u2014 one that omits the three scope fields and `confirmSpend` \u2014 still returns 201/200 and carries a `deprecations` entry naming exactly what to add. FROM 2026-11-01 the same request is refused: a missing scope field is `400` `scope_required` naming the field, and a scope that is stated but not confirmed is `409` `spend_confirmation_required` \u2014 the same refusal a request pinned to `contractVersion: \"2026-11-01\"` already gets today, which is how you test the new behaviour before the date. THE EXACT CHANGE IS FOUR KEYS: add \"creditCap\": 100, \"captureMode\": \"depth\", \"datePosted\": \"PAST_WEEK\" and \"confirmSpend\": true to the request body. Those three values are what the dashboard's \"Search scope\" step pre-fills, offered so you can copy them DELIBERATELY \u2014 they are NOT server-side defaults and nothing is chosen for you, so send the caps you actually want, and send `confirmSpend` only once the person has heard the daily figure. THE VERSION STRING AND THE CUT-OVER DATE ARE THE SAME DAY, which they did not have to be: the version is what you PIN, the date is what happens to you if you do not.\n\nIT RUNS EVERY DAY, AND CHARGES EVERY DAY. This is not a one-off search: once created, the sweep repeats roughly every 24 hours until you untrack it, and `creditCap` is PER RUN, not a total for the life of the search. The first sweep starts within seconds of creation, not a day later, and the daily cadence runs from there. CREATED, OR THE ONE YOU ALREADY HAVE: a keyword search's identity is its joined terms, so creating one whose keywords match a search this team already has \u2014 whether that search is ACTIVE or one you DELETED \u2014 returns THAT search instead of making a second one. The response is 200 with `resumed: true` rather than 201, and `seenPosts` says how many posts it has already swept and will therefore SKIP; a genuine create returns 201 with `resumed: false` and `seenPosts: 0`. THE SETTINGS YOU SEND ARE APPLIED TO THAT SEARCH AND THE ONES YOU OMIT ARE LEFT AS THEY ARE, so a bare `{\"keywords\": [...]}` returns it unchanged rather than resetting its name, its AI filter and its caps to the defaults; send an explicit `null` to clear a field. For the same reason `name` and `aiProvider` in the response are the search's CURRENT values, not an echo of what you sent. RESUMING A DELETED SEARCH ALSO TRACKS IT AGAIN and queues a sweep within seconds, which charges \u2014 if you did not mean to bring it back, untrack it again with DELETE /api/v1/keyword/{id}. A `creditCap` of 2000 means up to 2000 enriching credits EVERY DAY, not 2000 once. Set the caps to what one day may cost, and remove the search with DELETE /api/v1/keyword/{id} when you no longer want it running.\n\nEach run sweeps only posts it has not captured before \u2014 a post captured once is never re-swept, so successive runs return new people rather than re-charging for the same ones.\n\nONE LIMIT: `creditCap`. It is the only limit you set on a search that captures people, and a run ends when it has spent it, when the provider has no new posts, or on the walk's safety bound (20 provider pages per term, and at most 2,000 posts scanned in one run) \u2014 never because a post count was reached. A credit is charged per NEW PERSON for this source, not per post or repeat engagement, so one post with 300 distinct new people can cost up to 300 credits. A `posts_only` search captures no people and is bounded by `postsPerSync` new posts or `creditCap` credits, whichever is lower. The retired per-run post limit, `postBudget`, is accepted and IGNORED if you still send it (since 30 September 2026), and the reply lists it in `ignoredFields`.\n\nWHAT ONE DAY CAN COST, AS A FORMULA \u2014 THE ONE PLACE IT IS WRITTEN DOWN. Every surface that quotes a spend estimate computes it the same way, from one shared function: `estimatedDailyMax = creditCap` for a search that captures people \u2014 with or without post authors \u2014 and `estimatedDailyMax = min(postsPerSync, creditCap)` for a `posts_only` search, which buys at most `postsPerSync` new posts a day at one credit each. POST /api/v1/keyword/estimate, this endpoint's 201/200 and its 409, and PATCH /api/v1/keyword/{id} all quote that one figure. The fields that look like they belong in it are deliberately not in it. `captureMode` is not, because `depth` and `breadth` only choose how the cap is SPREAD across a run's posts, and both stop at the same cap. `maxEngagementsPerPost` is not, and that is the one that surprises: the per-post ceiling times the posts a run reaches is often what a run actually spends, but it is not a limit we can promise \u2014 a post whose engagement counts the provider did not return is allocated the whole remaining cap, so a run with a low per-post ceiling can still spend the full `creditCap`. The estimate is therefore the most a day can cost, never the least: an estimate that came in under what you are charged would be worse than no estimate at all. When `creditCap` is absent, zero, negative or not a whole number there is NO estimate, rather than an estimate of zero. Against your team's remaining enriching-credit balance the same calculation gives `daysToExhaustAtCap = floor(balance / estimatedDailyMax)`: WHOLE days at that rate, so a balance of 250 against a cap of 100 is 2 and not 3, and any balance below the cap is 0 \u2014 the very first sweep is the one that gets cut short. With no usable cap there is no rate to divide by, so `daysToExhaustAtCap` is absent too: never zero, never infinite. BOTH FIGURES ARE RETURNED, not merely documented: this endpoint carries `estimatedDailyMax` and `daysToExhaustAtCap` beside the created search on the 201 AND on the 200 resume, computed from the caps that will actually bind the next run, and GET /api/v1/sources carries the same two inside a keyword source's `config`, so a search created on any surface can be read back. Each is OMITTED rather than zeroed whenever it has no honest value \u2014 test for the key, not for a number.\n\nWHY A RUN STOPPED, AND WHAT IT DID, is reported by GET /api/v1/sources on the source's `lastRun` object, which carries seventeen fields answering four questions. WHEN AND WHY IT ENDED: `at`, `stoppedBy` (the category), `reason` (the sentence behind it \u2014 for `ai_error` a sentence Cornersight owns, not the provider's raw response) and `aiErrorCode`, present only on an `ai_error`, which is the stable token to branch on: `model_not_found`, `invalid_key`, `rate_limited`, `out_of_credit` (the provider account behind the key has no credit: add credit with the provider, do not rotate the key) or `provider_error` \u2014 and `provider_error` is the provider's own outage, never a reason to tell someone to rotate a working key. WHAT THE PROVIDER RETURNED BUT CORNERSIGHT COULD NOT SAFELY CAPTURE: `providerPageLimitReached` records whether the 20-page safety bound ended a term before exhaustion was proven; `providerRowsDropped` counts provider result rows skipped because they lacked a capturable activity URN; the walk continues past those pages instead of ending early. It is rows across terms/pages, not necessarily unique posts, and is omitted for unmeasured runs. HOW MUCH IT LOOKED AT: `postsScanned` \u2192 `postsKept` is the AI filter's before/after, while `postsHarvested` is how far the run actually REACHED, which can be far below `postsKept` when a cap stopped it early. AND, ON A SEARCH BUILT FROM AN `expression`, `discardedByExpression` \u2014 and `postsFilteredOut`, the same number under its older name \u2014 is how many harvested posts its AND/NOT terms discarded, so the run summary reads searched `postsScanned`, discarded `discardedByExpression`, kept `postsKept`. Both are OMITTED for a search without an expression and PRESENT AND 0 when one discarded nothing, which is the distinction between an expression to rewrite (`postsHarvested: 0` beside `discardedByExpression: 47`) and a search that simply found nothing (the keys absent). A discarded post IS counted in `postsScanned` and NOT in `postsKept`, and GET /api/v1/sources/{id}/kept-posts?include=swept names each one with the clause it failed. WHAT BECAME OF THE PEOPLE, which is how a run that yielded almost nothing is diagnosed: `engagersSeen` is the denominator, `engagersDropped` counts engagers seen but not turned into leads \u2014 since 2026-09-08 that no longer means 'had no public handle', because those are now captured under their member URN; what is left is an engager with no identity at all, plus organisation pages, which were never leads, `engagersDuplicate` includes already-stored rows and free repeat engagements, `repeatEngagements` counts the latter subset, `leadsWritten` is inserted engagement rows, and `creditsSpent` counts newly charged people under the name that says what it cost \u2014 one credit is one newly chargeable person per source. The five capture counters are OMITTED, never zeroed, for runs predating them. Each field is documented on GET /api/v1/sources. `lastRun` is `null` for person, company and post sources (they have no sweep of their own) and is ALWAYS an object for a keyword search - with null members until the first run finishes, so \"not a keyword source\" stays distinguishable from \"has not run yet\". `stoppedBy` is one of `credits` (reached creditCap - the real spend bound; on a `posts_only` search only when creditCap was below postsPerSync), `post_limit` (a `posts_only` search bought its postsPerSync posts for the day - raise postsPerSync, not creditCap, for more), `exhausted` (the provider walk ended without a capture cap; `providerPageLimitReached: true` proves the walk's safety bound fired - 20 pages per term, or 2,000 posts in the run; false only says it did not, because an all-seen RELEVANCE page can also stop before later unseen posts), `error` (the sweep itself failed), `ai_error` (the team's own AI credential failed - the search is otherwise fine) or `capture_empty` (the sweep harvested real posts and every engager fetch answered and returned NOBODY - a CAPTURE failure, never a narrow search; a run that harvested nothing is a clean `exhausted` instead) - and `budget` only on a run from before 30 September 2026, when a per-run post limit existed that has since been retired. The difference that matters most: `credits` and `post_limit` mean a capture cap ended the run; `exhausted` means the provider walk ended without one. `providerPageLimitReached: true` proves the page bound fired; false only says it did not, because an all-seen RELEVANCE page can also stop before later unseen posts.\n\nWHEN AN EDIT TAKES EFFECT: FROM THE NEXT RUN, NEVER THE ONE ALREADY RUNNING. A sweep reads the search's configuration ONCE, when it starts, and holds it for the whole run - so settings changed while a sweep is in flight do not re-bind it, and a run that began on the old budgets finishes on them. A change made BEFORE the sweep starts does bind that run, including one made after its job was queued: the worker reads the configuration when it picks the job up, not when the job was created. That is why a run's counts can look like a cap overrun and not be one - read `lastRun.config` on GET /api/v1/sources, which is the snapshot of what THAT run was bound by, against `config`, which is what the search is set to now and what its next run will use. `lastRun.config` is omitted for runs that predate it and is never back-filled from the current settings.\n\nON A TRIAL a team may hold at most 2 keyword searches, and each captures at most 250 leads.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TrackKeywordRequest"
              },
              "examples": {
                "minimal": {
                  "summary": "Keyword only \u2014 no AI filter",
                  "value": {
                    "keywords": [
                      "AI agents"
                    ]
                  }
                },
                "filtered": {
                  "summary": "Three terms, provider filters and an AI criterion",
                  "value": {
                    "datePosted": "PAST_WEEK",
                    "sort": "DATE_POSTED",
                    "aiProvider": "openai",
                    "aiModel": "gpt-4o-mini",
                    "aiPrompt": "Keep posts where the author is hiring. Reject recruiters advertising services.",
                    "creditCap": 100,
                    "keywords": [
                      "hiring engineers",
                      "we are hiring",
                      "join our team"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Nothing was created: this team already had a search with these keywords, and it is returned with `resumed: true`. Settings you sent were applied to it; ones you omitted were left as they were. If it had been untracked it is now tracked again, with a sweep queued. `estimatedDailyMax` and `daysToExhaustAtCap` therefore describe the search AS IT NOW STANDS, not your request: a bare `{\"keywords\"}` returns the price of the search that was already there. A resumed search sweeps within seconds too, so the daily figure is as worth relaying here as on a create.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TrackKeywordResponse"
                }
              }
            }
          },
          "201": {
            "description": "The keyword search is now tracked. `resumed` is `false` and `seenPosts` is `0`: this search is new. `estimatedDailyMax` says what one day of it can cost and `daysToExhaustAtCap` how long your balance funds that \u2014 quote the daily figure to whoever asked for the search, because the first sweep runs within seconds of this response. Either is omitted when it has no honest value.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TrackKeywordResponse"
                }
              }
            }
          },
          "400": {
            "description": "`keywords` is missing, empty, longer than ten terms, contains a duplicate or an over-long term, or aiProvider/aiPrompt were not supplied together. Also returned with code `invalid_enum_value` when `sort`, `datePosted`, `contentType`, `captureMode` or `aiProvider` is not one of its declared values \u2014 the endpoint validates these before creating anything, so a rejected request leaves no source behind. Also returned with code `invalid_number` when `creditCap` or `maxEngagementsPerPost` is not a whole number within its declared range \u2014 a value below the minimum, above the maximum, fractional, or not a number at all. These were previously ACCEPTED and silently replaced by the field's default: `creditCap: 0` returned 201 and stored 100, authorising 100 enriching credits every day. They are rejected before anything is created, so nothing is stored and no sweep is queued. Also returned with code `scope_required` once the SPEND CONTRACT is enforced (SCHEDULED FOR 2026-11-01), when `creditCap`, `captureMode` or `datePosted` is missing (an explicit `null` counts as missing): the body carries `field` naming which one, and the message states the exact JSON to add, offering the value the dashboard pre-fills so you can copy it deliberately \u2014 there is no server-side default. One field per refusal, in the dashboard's own order. Also returned with code `invalid_contract_version` \u2014 in BOTH phases \u2014 for a `contractVersion` outside the accepted list, and without a code for a `confirmSpend` that is not a boolean. Every one of these is refused before anything is created. Also returned with code `invalid_urn` when a value in `authorIndustry`, `authorCompany`, `fromPerson`, `fromCompany`, `mentionsPerson` or `mentionsCompany` is not an id of the right shape. The body carries `field`, `value`, `expected` and `example` beside the message, so the fix needs no sentence parsing. A HANDLE IS NOT A URN: `fromPerson: [\"jasonlemkin\"]` used to be accepted and then failed at the provider on EVERY daily run, reported as a retryable outage. Resolve a handle for free with `GET /api/v1/profile/{username}/urn`. `authorKeyword` is plain words and is NOT checked."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TrackedProfileDeleteForbidden"
          },
          "409": {
            "description": "Code `spend_confirmation_required`. The scope was stated but the spend was not confirmed: `confirmSpend` was absent or `false`, and the contract is being enforced (the cut-over \u2014 SCHEDULED FOR 2026-11-01 \u2014 has passed, or this request pinned `contractVersion: \"2026-11-01\"`). NOTHING WAS CREATED OR CHANGED \u2014 no source, no sweep, no charge \u2014 and re-sending the identical request with `\"confirmSpend\": true` is what resolves it. The body carries the numbers the dashboard shows above its Confirm button, from the same shared function: `creditCap`, `captureMode` and `datePosted` (the settings the next run would be bound by \u2014 on a resume those are the live search's, where this request omitted them), `estimatedDailyMax`, `remainingBalance` and `daysToExhaustAtCap`, plus `contractVersion`. The last three are OMITTED rather than nulled when there is no honest number \u2014 test for the KEY, as everywhere else on this endpoint. SHOW THOSE NUMBERS TO THE PERSON before retrying, and do not shrink the caps to get past the refusal. The body ALSO carries `filtering`: `applied` (always `false` when a suggestion is quoted), `suggestion` (one sentence, written for the person, mirroring the dashboard's step 2 \u2014 \"Every matched post is tracked while this is off.\") and `aiKeyStoredFor` (which of `openai`, `grok`, `gemini`, `claude` this team has a key for, sorted and possibly empty). \u26a0\ufe0f IT IS NOT PART OF THE REFUSAL. This `409` is about `confirmSpend` and nothing else; a create carrying no filter has never been refused, and re-sending it with `\"confirmSpend\": true` and still no filter is a `201`. Offer the filter alongside the cost figures ONCE and respect the answer. `suggestion` names a prompt only when `aiKeyStoredFor` is non-empty, because a key is stored in the dashboard and cannot be sent here. Filtering reduces daily spend by rejecting posts before their engagers are captured; an AI prompt still scores every fetched post on the team's own provider key, so it trades one bill against another rather than removing one. Also returned with code `identifier_in_use`. A source identifier is unique per team across all four kinds, and these joined keywords are already held by a source of a DIFFERENT kind (a profile, a company page or a tracked post) \u2014 the message names which. Use different terms; nothing was created. ALSO RETURNED WITH CODE `settings_conflict`, and that one is not about money at all. These keywords already name a live search, so this call would RESUME it and rewrite settings it is running on today \u2014 a targeting filter, `sort`, `datePosted`, `contentType` or the AI filter. NOTHING WAS CHANGED. The body carries `previous` (the search's FULL current settings), `settings` (what it would become) and `changed` (each differing field as `{ field, from, to }`). Read `changed` to the person who asked, then re-send the identical request with `\"confirmChanges\": true`. THAT IS THE ONLY FIELD THAT ACKNOWLEDGES A REWRITE: `\"confirmSpend\": true` authorises the recurring CHARGE and nothing else, so a body that RAISES A CAP *and* rewrites a setting carries BOTH flags, and each refusal names the one still missing. It was accepted for both once, which made this refusal unreachable from the CLI \u2014 `track-keyword` requires `--confirm-spend`, so every create it sent arrived pre-acknowledged \u2014 and from any caller that sent it early. \u26a0\ufe0f AND THE `spend_confirmation_required` 409 ABOVE CARRIES `changed` TOO whenever the same body would also rewrite a setting: it is answered FIRST and it asks you to re-send with `confirmSpend`, so it names the rewrite as well and one reading is enough to send both flags together. To leave the live search alone, omit those fields: an omitted setting is never rewritten, so a bare `{\"keywords\"}` returns the search unchanged. A resume that changes nothing, or that only LOWERS a cap, is a plain 200. The caps are deliberately NOT in `changed` \u2014 a RAISE has its own `spend_confirmation_required` refusal and lowering one is never refused \u2014 and neither is `name`, which costs nothing and changes no behaviour.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          },
          "502": {
            "description": "The current keyword search settings could not be verified because a database read failed. Nothing is changed, queued, or charged by this request. Retry when the database is available.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/keyword/{id}": {
      "patch": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Update a keyword search's settings",
        "operationId": "updateKeyword",
        "description": "Change a live keyword search's settings \u2014 its caps, its scope, its AI filter and its provider-side targeting \u2014 without re-creating it.\n\nWHY IT EXISTS. Until now the only way to re-cap a live search from outside the dashboard was to call POST /api/v1/keyword/track again with the same keywords, which RESUMES the search and applies whatever settings the call carries. That works, but it makes \"raise the cap on my live search\" and \"create a search\" the same request. This is the request that says which.\n\nPartial update: only the fields present in the body change, an omitted one is left exactly as it is, and an explicit `null` clears it \u2014 to null on a nullable column, and to the create-time default on a NOT NULL one (`creditCap` 100, `captureMode` depth, `sort` DATE_POSTED, `datePosted` PAST_WEEK), which is the only coherent reading of \"clear\" for those.\n\n\u26a0\ufe0f A CHANGE THAT RAISES WHAT ONE DAY CAN COST NEEDS `\"confirmSpend\": true` \u2014 that figure is `estimatedDailyMax`: `creditCap` on a search that captures people, `min(postsPerSync, creditCap)` on a `posts_only` search, so raising `creditCap` alone on a `posts_only` search whose `postsPerSync` is below it needs no confirmation \u2014 and is otherwise a `409` `spend_confirmation_required` whose message names the search and the new daily cap, carrying `previous` (the caps the search holds now), the resulting `creditCap`/`captureMode`/`datePosted`, `raised` (which setting pushed the figure up: `creditCap`, or `postsPerSync`), `estimatedDailyMax`, `remainingBalance` and `daysToExhaustAtCap` \u2014 the same numbers the dashboard puts above its Confirm button, and the same 409 shape POST /api/v1/keyword/track returns. NOTHING WAS CHANGED; re-send the identical request with `\"confirmSpend\": true` to authorise it, and do not shrink the cap to get past the refusal. LOWERING a cap, leaving it alone, or changing anything else needs no confirmation, because none of those increases what a day can cost. The rule is NOT tied to the spend contract's cut-over date: it applies today, on this endpoint and on a resume alike.\n\nWHEN IT TAKES EFFECT: FROM THE NEXT RUN. A sweep reads its settings once, when it starts, so a run already in flight finishes on the caps it began with \u2014 read `lastRun.config` on GET /api/v1/sources for what the last run was actually bound by, and `config` for what the next one will use. An edit made before the sweep starts, including after its job is queued, does bind that run.\n\n`keywords` CANNOT BE CHANGED HERE and sending it is a `400`, not a silent drop: a search's terms are its identity, and its seen-set is keyed to the SEARCH rather than to a term, so new terms would inherit the old ones' swept posts and sweep a smaller universe than they appear to. Create a search with the new terms and untrack this one.\n\nIdentify the search by the SOURCE `id` GET /api/v1/sources returns \u2014 the same id DELETE /api/v1/keyword/{id} takes and POST /api/v1/keyword/track echoes back \u2014 never by its keyword text, which is what `username` shows. Mirrored as the CLI's `keyword-update` and MCP's `update_keyword`. UNKNOWN FIELDS ARE REFUSED: a property not listed here is a 400 carrying `code: \"unknown_field\"` and naming the offending field, rather than a 200 that silently dropped it.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The keyword search's source id, from GET /api/v1/sources. Not its keyword text, and not the dashboard's keyword_searches.id."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "description": "Partial update: only the fields present in the body change, an omitted one is left exactly as it is, and an explicit `null` clears it \u2014 to null on a nullable column, and to the create-time default on a NOT NULL one (`creditCap` 100, `captureMode` depth, `sort` DATE_POSTED, `datePosted` PAST_WEEK), which is the only coherent reading of \"clear\" for those.",
                "properties": {
                  "name": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 200,
                    "description": "What the search is called in the source list. `null` clears it, and the list then shows the terms themselves."
                  },
                  "sort": {
                    "type": "string",
                    "enum": [
                      "RELEVANCE",
                      "DATE_POSTED"
                    ],
                    "description": "How the provider orders the posts each sweep considers. `null` resets it to DATE_POSTED. \u26a0\ufe0f Stored and reported back, but every keyword search currently RUNS BY RELEVANCE whatever this holds (since 5 October 2026: newest-first returned nothing for high-volume terms)."
                  },
                  "datePosted": {
                    "type": "string",
                    "enum": [
                      "PAST_24_HOURS",
                      "PAST_WEEK",
                      "PAST_MONTH"
                    ],
                    "description": "How far back each sweep looks. A SCOPE field. `null` resets it to PAST_WEEK."
                  },
                  "contentType": {
                    "type": "string",
                    "enum": [
                      "VIDEO",
                      "IMAGE",
                      "JOB",
                      "LIVE_VIDEO",
                      "DOCUMENT",
                      "COLLABORATIVE_ARTICLE"
                    ],
                    "description": "Restrict the sweep to one kind of post. `null` clears the restriction."
                  },
                  "authorIndustry": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Keeps only posts whose author is in one of these industries. A NUMERIC LinkedIn industry id. Send it bare (`96`) or wrapped (`urn:li:industry:96`); both are accepted and stored as the wrapped form. An industry NAME is not an id. FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN \u2014 `\"jasonlemkin\"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run. Send `[]` to clear this filter \u2014 the column is `text[] NOT NULL DEFAULT '{}'`, so `[]` IS its unset state."
                  },
                  "authorCompany": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Keeps only posts whose author currently works at one of these companies. A NUMERIC LinkedIn organisation id. Send it bare (`1441`) or wrapped (`urn:li:organization:1441`); both are accepted and stored as the wrapped form. A company NAME or slug is not an id. FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN \u2014 `\"jasonlemkin\"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run. Send `[]` to clear this filter \u2014 the column is `text[] NOT NULL DEFAULT '{}'`, so `[]` IS its unset state."
                  },
                  "authorKeyword": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Plain words matched against the AUTHOR (headline, title), not the post text \u2014 the one filter here that is NOT a URN. Send `[]` to clear this filter \u2014 the column is `text[] NOT NULL DEFAULT '{}'`, so `[]` IS its unset state."
                  },
                  "fromPerson": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Keeps only posts written by these people. A LinkedIn MEMBER ID \u2014 `\"AC\"` followed by base64url characters, about 39 in all. Send it bare (`ACoAAA5eqCEBzmkNfjyOp9_MseBpRQ-P17SuIos`) or wrapped (`urn:li:person:ACoAAA5eqCEBzmkNfjyOp9_MseBpRQ-P17SuIos`); both are accepted and stored as the wrapped form, so `GET /api/v1/sources` reports one spelling whichever you sent. This is the SAME id profile enrichment returns as `entityUrn`. No member id to hand? `GET /api/v1/profile/{username}/urn` resolves any public handle for free \u2014 no enrichment, no tracking, `creditsCharged: 0` (CLI `profile-urn`, MCP `get_profile_urn`). FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN \u2014 `\"jasonlemkin\"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run. Send `[]` to clear this filter \u2014 the column is `text[] NOT NULL DEFAULT '{}'`, so `[]` IS its unset state."
                  },
                  "fromCompany": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Keeps only posts published by these company pages. A NUMERIC LinkedIn organisation id. Send it bare (`1441`) or wrapped (`urn:li:organization:1441`); both are accepted and stored as the wrapped form. A company NAME or slug is not an id. FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN \u2014 `\"jasonlemkin\"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run. Send `[]` to clear this filter \u2014 the column is `text[] NOT NULL DEFAULT '{}'`, so `[]` IS its unset state."
                  },
                  "mentionsPerson": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Keeps only posts that @mention one of these people. A LinkedIn MEMBER ID \u2014 `\"AC\"` followed by base64url characters, about 39 in all. Send it bare (`ACoAAA5eqCEBzmkNfjyOp9_MseBpRQ-P17SuIos`) or wrapped (`urn:li:person:ACoAAA5eqCEBzmkNfjyOp9_MseBpRQ-P17SuIos`); both are accepted and stored as the wrapped form, so `GET /api/v1/sources` reports one spelling whichever you sent. This is the SAME id profile enrichment returns as `entityUrn`. No member id to hand? `GET /api/v1/profile/{username}/urn` resolves any public handle for free \u2014 no enrichment, no tracking, `creditsCharged: 0` (CLI `profile-urn`, MCP `get_profile_urn`). FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN \u2014 `\"jasonlemkin\"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run. Send `[]` to clear this filter \u2014 the column is `text[] NOT NULL DEFAULT '{}'`, so `[]` IS its unset state."
                  },
                  "mentionsCompany": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Keeps only posts that @mention one of these company pages. A NUMERIC LinkedIn organisation id. Send it bare (`1441`) or wrapped (`urn:li:organization:1441`); both are accepted and stored as the wrapped form. A company NAME or slug is not an id. FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN \u2014 `\"jasonlemkin\"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run. Send `[]` to clear this filter \u2014 the column is `text[] NOT NULL DEFAULT '{}'`, so `[]` IS its unset state."
                  },
                  "aiProvider": {
                    "type": "string",
                    "enum": [
                      "openai",
                      "grok",
                      "gemini",
                      "claude"
                    ],
                    "description": "Which STORED key filters the posts. THE KEY ITSELF IS NOT ACCEPTED HERE and is returned by no endpoint. `null` turns the AI filter off. THE TRIO MOVES AS ONE: naming any of aiProvider/aiModel/aiPrompt rewrites all three, because a prompt with no provider (or a model naming a provider that is no longer set) is a state the create path refuses, and patching them independently is the only way to reach it."
                  },
                  "aiModel": {
                    "type": "string",
                    "description": "Model id for the chosen provider. Optional even with aiProvider: omit it (or send `null`) and the provider default runs \u2014 openai gpt-6-luna, grok grok-4.3, gemini gemini-3.5-flash-lite, claude claude-haiku-4-5-20251001. Part of the AI trio above."
                  },
                  "aiPrompt": {
                    "type": "string",
                    "description": "The criterion, in plain language. Required when aiProvider is set. Part of the AI trio above. Every post a run scans is sent to your own AI provider on your key \u2014 up to 2,000 posts in each run, 10 to a call \u2014 and billed by that provider; `creditCap` does not bound it."
                  },
                  "postBudget": {
                    "type": "integer",
                    "deprecated": true,
                    "description": "RETIRED on 30 September 2026 and IGNORED: the per-run post limit is no longer a setting, and a search's one spend limit is `creditCap`. Accepted with any value so an older client keeps working \u2014 it changes nothing, never needs `confirmSpend`, and is listed back in `ignoredFields`."
                  },
                  "creditCap": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 2147483647,
                    "description": "Maximum enriching credits one run may spend, PER RUN \u2014 the search repeats about every 24 hours, so N is up to N credits EVERY DAY until it is untracked. RAISING it needs `confirmSpend: true`; lowering it does not. There is no product ceiling: 2147483647 is only what the column can hold."
                  },
                  "mode": {
                    "type": "string",
                    "enum": ["engagers", "posts_only"],
                    "description": "engagers (default) captures leads. posts_only stores matching post text, charges one credit per new kept post, and captures no people or leads."
                  },
                  "postsPerSync": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 60,
                    "description": "Required for posts_only. Maximum new posts charged in one daily run; repeats and rejected posts are free. A raise requires confirmSpend."
                  },
                  "captureEngagers": {
                    "type": "boolean",
                    "default": true,
                    "description": "Capture the people who ENGAGED with each kept post — likes and comments. Default true, which is what every keyword search has always done. false fetches no reactions or comments at all (no provider calls for them) and requires `capturePostAuthors: true`: a search must capture someone (400 `no_capture_target` otherwise). Engagers mode only — sent with `mode: \"posts_only\"` it is a 400. Omitted on a resume or PATCH leaves the stored value alone. Turning it back ON for a search that captured post authors only needs `confirmSpend: true` (409 `spend_confirmation_required` otherwise): the daily figure is `creditCap` either way, but authors only can charge at most one new person per kept post, while engagers can spend the whole cap on one busy post."
                  },
                  "capturePostAuthors": {
                    "type": "boolean",
                    "default": false,
                    "description": "Capture the person who WROTE each kept post, as a lead with `engagementType: \"Author\"`. Default false. Read from the keyword search result itself, so it costs no extra provider call. Charged exactly like an engager: ONE credit per NEW person for this search; the same person again — on a later post, or also as a liker or commenter of the same post — is a free repeat (the two rows are kept, the person is charged once). A post whose author is a COMPANY PAGE captures no author and costs nothing; the run reports how many as `lastRun.companyAuthorsSkipped`. The credit cap, the team's daily keyword ceiling and a trial source's lead cap bind authors exactly as they bind engagers. With `captureEngagers: false` a run adds at most one new person per kept post, and `estimatedDailyMax` is still `creditCap` \u2014 the one limit you set. Engagers mode only."
                  },
                  "captureMode": {
                    "type": "string",
                    "enum": [
                      "depth",
                      "breadth"
                    ],
                    "description": "How creditCap is spent across a run's posts. depth lets one post spend the whole remaining cap; breadth shares the cap across the run's posts by their engagement counts and redistributes the unspent. Changing it does not change the cap, so it never needs confirmation. `null` resets it to depth."
                  },
                  "maxEngagementsPerPost": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 2147483647,
                    "description": "Breadth mode only: cap each post at this many engagements. `null` clears it back to no explicit ceiling (spread the whole creditCap across posts). creditCap stays the hard limit, so this only shapes the spend beneath it and never raises it."
                  },
                  "captureReplies": {
                    "type": "boolean",
                    "description": "Whether reply authors are captured as leads. Defaults to true for existing and new sources; false skips reply authors before lead writes and credit charges."
                  },
                  "enrichLeads": {
                    "type": "boolean",
                    "description": "Whether this source's leads are enriched. Default true, what every source has always done. RAW MODE when false: this source's engagers are still captured and charged exactly as before \u2014 one credit per NEW person per source, repeats free, the same ledger and caps \u2014 but NEVER enriched: no job title, company or country, and no enrichment provider call. Those leads end enrichment status `raw` and are read with GET /api/v1/leads/raw; they never appear in GET /api/v1/leads, /engagers, exports, webhooks or integrations. A plain setting, not a spend change: the price is the same either way, so it never needs `confirmSpend`. It applies to leads captured or processed AFTER the change \u2014 leads already enriched stay enriched and raw leads stay raw. Stored on the search's tracked source (like captureReplies), not among its keyword settings, so `settings` in the response does not carry it; it is echoed at the top level when sent. Omit it to leave the stored setting alone."
                  },
                  "runOnce": {
                    "type": "boolean",
                    "default": false,
                    "description": "Harvest ONCE, then stop scheduling. `false` clears the bound and returns the search to the unbounded daily cadence. A run that FAILED does not satisfy it \u2014 see `maxRuns` for what counts \u2014 so this means one harvest, not one attempt. SETTING IT TO false ON A SEARCH THAT ALREADY STOPPED FOR IT RESTARTS THE SEARCH: the stop is cleared and the search is queued in this same call."
                  },
                  "endAt": {
                    "type": "string",
                    "format": "date-time",
                    "description": "A UTC instant after which this search stops scheduling; `null` clears it. Must be in the FUTURE \u2014 a date already past is a 400, and nothing is written. Moving a passed end date forward RESTARTS a search that stopped for it: the stop is cleared and the search is queued in this same call."
                  },
                  "maxRuns": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 3650,
                    "description": "Stop after this many COUNTED runs; `null` clears the budget. A run COUNTS when it reached the provider and ended ordinarily (`exhausted`, `credits`, `post_limit`, or a `team_cap`/`lead_cap` that bound it mid-sweep); a FAILED run (`error`, `ai_error`), an untracked run and a run skipped before the provider was asked do NOT count, and a sweep that captured nobody DOES. \u2b50 RAISING IT ABOVE `schedule.runsCompleted` IS HOW A STOPPED SEARCH IS RESTARTED, and this endpoint does both halves: it clears `schedule.stoppedAt`/`stoppedReason` AND makes the source due again, so the next sweep runs within a minute rather than never. An edit that leaves the search stopped \u2014 a budget still at or below the runs it has had \u2014 deliberately does NOT clear the reason it is stopped."
                  },
                  "confirmSpend": {
                    "type": "boolean",
                    "description": "THE AUTHORISATION FOR A CAP RISE, and the one field you must not supply on the person's behalf without asking. Required ONLY when this update would RAISE what one day of the search can cost \u2014 its `creditCap`, or `min(postsPerSync, creditCap)` on a `posts_only` search \u2014 above what it is now; a lowering, an equal figure, and an update that moves neither are all accepted without it. Send true only after the person has heard the daily figure the 409 quotes."
                  },
                  "contractVersion": {
                    "type": "string",
                    "enum": [
                      "2026-09-09",
                      "2026-11-01"
                    ],
                    "description": "WHICH REVISION OF THE SPEND CONTRACT TO BE HELD TO, accepted here so that a body this endpoint takes is a body POST /api/v1/keyword/track and POST /api/v1/keyword/estimate also take. THE ACCEPTED VALUES ARE THE SAME TWO, and ANY OTHER VALUE \u2014 a misspelled date, a word, a number, a non-string \u2014 IS A `400` with code `invalid_contract_version`, in both phases, BEFORE ANY COLUMN IS WRITTEN. A misspelled pin that was silently dropped would mean believing you had moved when you had not, and on an update it would be dropped from a call that still succeeded.\n\n\u26a0\ufe0f IT CHANGES NOTHING ABOUT THIS ENDPOINT'S ANSWER, and that is deliberate rather than an oversight. The confirmation gate on a cap RISE is phase-independent and version-independent: `confirmSpend: true` is required to raise what one day can cost whichever version you pin and whichever phase the server is in, because \"confirmation is required on any update that RAISES the cap\" has been published since the contract shipped and there is no caller written against a laxer rule. Pin it if you pin it everywhere else; the only thing it buys here is that a typo is told to you."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The settings as they now stand, RE-SELECTED FROM THE DATABASE rather than echoed from the request \u2014 so every field this call left alone is reported at its real value, not as the null the body implied. `{ id, username, profileType: \"keyword\", settings: { name, keywords, sort, datePosted, contentType, creditCap, captureMode, maxEngagementsPerPost, aiProvider, aiModel, aiPrompt, authorIndustry, authorCompany, authorKeyword, fromPerson, fromCompany, mentionsPerson, mentionsCompany, expression } }`, plus `estimatedDailyMax` and `daysToExhaustAtCap` recomputed from what was stored \u2014 both OMITTED, never null and never 0, when there is no honest number \u2014 and `ignoredFields` when the body still sent the retired `postBudget`.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "id",
                    "username",
                    "profileType",
                    "settings"
                  ],
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid",
                      "description": "The SOURCE id this call was addressed by."
                    },
                    "username": {
                      "type": "string",
                      "description": "The search's keyword text, exactly as GET /api/v1/sources reports it."
                    },
                    "profileType": {
                      "type": "string",
                      "enum": [
                        "keyword"
                      ]
                    },
                    "settings": {
                      "type": "object",
                      "description": "Every settings field, as stored after this update."
                    },
                    "enrichLeads": {
                      "type": "boolean",
                      "description": "Present only when the request named it: the raw-mode setting now stored on the search's tracked source, re-read after the write (false = raw)."
                    },
                    "estimatedDailyMax": {
                      "type": "integer",
                      "description": "The most ONE DAY of this search can cost in enriching credits. Omitted when the cap is unusable."
                    },
                    "daysToExhaustAtCap": {
                      "type": "integer",
                      "description": "Whole days the team's remaining balance funds at that rate. Omitted when there is no rate or no readable balance."
                    },
                    "ignoredFields": {
                      "type": "array",
                      "items": {
                        "type": "string",
                        "enum": [
                          "postBudget"
                        ]
                      },
                      "description": "The fields this request sent that NOTHING READS ANY MORE, so a setting you still send is never dropped in silence. Today that can only be `postBudget` \u2014 the per-run post limit, retired on 30 September 2026: accepted with any value, applied to nothing, and named here. ABSENT when the request sent none."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The body named no setting at all; or it carried `keywords`/`keyword`, which cannot be changed here; or a setting was invalid \u2014 the SAME checks POST /api/v1/keyword/track applies, from the same validator, with the same codes: `invalid_enum_value` for `sort`, `datePosted`, `contentType`, `captureMode` or `aiProvider`, `invalid_number` for `creditCap` or `maxEngagementsPerPost` outside its declared range, and the aiProvider/aiPrompt both-or-neither rule. Also returned with code `invalid_contract_version` for a `contractVersion` outside the accepted list \u2014 the same 400 the create and the estimate return for the same value, in both phases; a pin this endpoint does not recognise is refused rather than dropped, so a typo cannot read as an update that honoured it. Also returned when the path segment is not a source id. Nothing was changed. Also returned with code `invalid_urn` when a value in `authorIndustry`, `authorCompany`, `fromPerson`, `fromCompany`, `mentionsPerson` or `mentionsCompany` is not an id of the right shape. The body carries `field`, `value`, `expected` and `example` beside the message, so the fix needs no sentence parsing. A HANDLE IS NOT A URN: `fromPerson: [\"jasonlemkin\"]` used to be accepted and then failed at the provider on EVERY daily run, reported as a retryable outage. Resolve a handle for free with `GET /api/v1/profile/{username}/urn`. `authorKeyword` is plain words and is NOT checked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "This team has no keyword search with that source id \u2014 the same answer another team's id gets, and the answer a source row with no settings row gets.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "409": {
            "description": "Code `spend_confirmation_required`. This update would RAISE what one day of the search can cost \u2014 `estimatedDailyMax`, which is `creditCap`, or `min(postsPerSync, creditCap)` on a `posts_only` search \u2014 and `confirmSpend` was not `true`. NOTHING WAS CHANGED \u2014 no column written, no sweep re-bound. The message describes THIS EDIT: it names the search and the daily cap it would be raised to, because the caller named the search by id and sent no keywords (the create's resume sentence about keywords belonging to a search is not used here). The body is the same shape POST /api/v1/keyword/track's 409 carries (`code`, `contractVersion`, the resulting `creditCap`/`captureMode`/`datePosted`, `estimatedDailyMax`, `remainingBalance`, `daysToExhaustAtCap`, and `filtering`) plus `previous`, the three the search holds NOW, and `raised`, naming which setting pushed the figure up. `filtering` is the same object the create's `409` carries \u2014 `applied`, `aiKeyStoredFor` and, only when `applied` is `false`, `suggestion` \u2014 describing the search AS THIS UPDATE WOULD LEAVE IT: a filter this request does not name is inherited from the search, so patching only a cap on an AI-filtered search reports `applied: true`. \u26a0 IT IS NOT PART OF THE REFUSAL. This `409` is about `confirmSpend` and nothing else; an unfiltered search has never been refused for being one, and re-sending with `\"confirmSpend\": true` and still no filter succeeds. SHOW THE PERSON THE BEFORE, THE AFTER AND THE DAILY FIGURE, then re-send the identical request with `\"confirmSpend\": true`. The last three numeric keys are OMITTED rather than nulled when there is no honest number \u2014 test for the KEY.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "delete": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Untrack a keyword search",
        "operationId": "untrackKeyword",
        "description": "Stop a keyword search and stop capturing from it. The delete half of the pair with POST /api/v1/keyword/track. Synchronous.\n\nSOFT DELETE, like untracking a profile or a post: the source is deactivated, never removed, because leads reference it and a row delete would cascade the captured leads away. Sweeping stops and the search leaves the tracked-source list; the leads it already captured are kept and stay listed by GET /api/v1/leads. REACH THEM AFTERWARDS with `GET /api/v1/leads?sourceKind=keyword`, which spans stopped searches as well as running ones, or put the search back in the list with `GET /api/v1/sources?includeInactive=true` \u2014 its id still works as a `profileId`. Without one of those two the search is no longer enumerable, and its leads are reachable only by an id you recorded before untracking. Untracking one that is already untracked returns `404` with `code: \"keyword_delete_already_stopped\"` and `error: \"That search is already stopped.\"` \u2014 DISTINGUISHABLE from the `404` for a search that never existed, which carries no code. The status is the same for both on purpose (untracking twice behaves as it did when the row was hard-deleted), but for a keyword search the two are not the same fact: an already-stopped search is still listed by `GET /api/v1/sources?includeInactive=true`, its id still resolves on `GET /api/v1/leads` and `GET /api/v1/engagers`, and its leads are still served. BRANCH ON `code`, never on the prose. It is the same value the dashboard has emitted for this case for months.\n\nIdentify it by the source `id` GET /api/v1/sources returns \u2014 the same value POST /api/v1/keyword/track echoes back as `id`. Not by the keyword text.\n\n`403` for a search created during a free trial: trial sources cannot be deleted on any surface, the dashboard included, until the team subscribes.\n\nStops the daily sweep and with it the daily charge. Leads already captured are kept. The `id` is the SOURCE id \u2014 the `id` field of the object POST /api/v1/keyword/track returned, and the one GET /api/v1/sources lists \u2014 not a keyword string.\n\nWHAT HAPPENS TO WORK THAT IS ALREADY RUNNING \u2014 the half this used to leave unsaid, and the half that costs money. A SWEEP ALREADY RUNNING IS STOPPED: the run asks whether its source is still tracked at every checkpoint it passes (before each provider call, before each post is harvested, before each chunk of lead rows) and abandons the run at the first checkpoint after the delete. THAT IS \u201cAT THE NEXT CHECKPOINT\u201d, NOT \u201cINSTANTLY\u201d: a provider call already in flight finishes first, and the check is coalesced behind a short window (one second by default), so expect the stop within moments rather than at the instant the delete returns. If the status read itself fails the run CARRIES ON to the next checkpoint \u2014 the control channel fails open on purpose, because abandoning a paying customer's sweep over one timed-out SELECT is the worse error. LEADS ALREADY WRITTEN ARE KEPT, and the run is finalised as `completed` with `stoppedBy: \"untracked\"` on GET /api/v1/sources/{id}/sync (and the username-keyed /sync routes). \u26a0\ufe0f THAT VALUE IS NOT IN `lastRun.stoppedBy` on GET /api/v1/sources and never will be: that enum describes how a keyword SWEEP ended and is a closed set. QUEUED ENRICHMENT IS WRITTEN OFF AND NOT CHARGED: enrichment lands minutes to hours after capture, so the backlog is the part of an untracked source that would otherwise go on billing \u2014 every lead of this source still `pending` or `processing` is marked `skipped` with the reason `source_untracked` and costs no enriching credits. Re-tracking this source returns exactly those leads to `pending` at the start of its next sync, and returns no other skipped lead with it.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The tracked source's id, from GET /api/v1/sources."
          }
        ],
        "responses": {
          "200": {
            "description": "The keyword search is untracked. The source is deactivated; its captured leads are kept.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Untracked"
                }
              }
            }
          },
          "400": {
            "description": "The path segment is not a source id."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TrackedProfileDeleteForbidden"
          },
          "404": {
            "description": "No keyword search with that id for this team, OR one that is already stopped \u2014 the two are told apart by `code`: \"keyword_delete_already_stopped\" for an already-stopped search (which is still enumerable with ?includeInactive=true and whose leads are still served), and no code at all when there is no such search."
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/v1/jobs/{jobId}/status": {
      "get": {
        "tags": [
          "Jobs"
        ],
        "summary": "Get job status",
        "operationId": "getJobStatus",
        "description": "Returns the current status for a Public API job owned by the authenticated team. This endpoint does not create jobs. For enrichment jobs, the completed result includes a creditsCharged field indicating how many enriching credits that job consumed. For company enrichment jobs the completed `result` matches the CompanyEnrichmentResult schema. For comment jobs (POST /api/v1/post/comments and /post/company-comments) it matches PostCommentsResult, whose rows are PostComment.",
        "parameters": [
          {
            "$ref": "#/components/parameters/JobId"
          }
        ],
        "responses": {
          "200": {
            "description": "Current job `status`. Jobs start as `pending`, move to `running` while the worker is processing them, and finish as `completed` or `failed`. When `completed`, the response includes the job `result`. The `result` shape varies by job type and by external LinkedIn API responses.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobStatus"
                },
                "examples": {
                  "pending": {
                    "summary": "Pending job",
                    "value": {
                      "status": "pending",
                      "result": null,
                      "error": null,
                      "createdAt": "2026-01-15T10:00:00.000Z",
                      "startedAt": null,
                      "completedAt": null
                    }
                  },
                  "running": {
                    "summary": "Running job",
                    "value": {
                      "status": "running",
                      "result": null,
                      "error": null,
                      "createdAt": "2026-01-15T10:00:00.000Z",
                      "startedAt": "2026-01-15T10:00:05.000Z",
                      "completedAt": null
                    }
                  },
                  "completed": {
                    "summary": "Completed job",
                    "value": {
                      "status": "completed",
                      "result": {
                        "message": "Demo result payload. Actual fields vary by job type."
                      },
                      "error": null,
                      "createdAt": "2026-01-15T10:00:00.000Z",
                      "startedAt": "2026-01-15T10:00:05.000Z",
                      "completedAt": "2026-01-15T10:00:20.000Z"
                    }
                  },
                  "failed": {
                    "summary": "Failed job",
                    "value": {
                      "status": "failed",
                      "result": null,
                      "error": "Demo failure reason",
                      "createdAt": "2026-01-15T10:00:00.000Z",
                      "startedAt": "2026-01-15T10:00:05.000Z",
                      "completedAt": "2026-01-15T10:00:20.000Z"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The path segment is not a jobId. A well-formed jobId for a job this team does not own answers 404 instead."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/SubscriptionInactive"
          },
          "404": {
            "$ref": "#/components/responses/JobNotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/v1/integrations/heyreach": {
      "get": {
        "tags": [
          "Integrations"
        ],
        "summary": "Read the HeyReach connection",
        "operationId": "getHeyReachStatus",
        "description": "Whether the team has connected HeyReach, when, whether HeyReach has since rejected the key (`keyRejected`: auto-push waits until the key is reconnected on the dashboard's Integrations page), and every auto-push rule with its state. Connecting a key is dashboard-only, like API keys: the API never takes, returns or logs one. Charges nothing.",
        "responses": {
          "200": {
            "description": "The connection and its auto-push rules.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HeyReachStatus"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/integrations/heyreach/campaigns": {
      "get": {
        "tags": [
          "Integrations"
        ],
        "summary": "List HeyReach campaigns that can take leads",
        "operationId": "listHeyReachCampaigns",
        "description": "The team's HeyReach campaigns that leads can join, read live from HeyReach with the team's stored key: in progress, paused, draft, scheduled or starting (finished, canceled and failed ones are left out). `canTakeLeads` is false for a campaign with no LinkedIn sender yet. Charges nothing.",
        "responses": {
          "200": {
            "description": "The campaigns.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "campaigns"
                  ],
                  "properties": {
                    "campaigns": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/HeyReachCampaign"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "description": "Code `not_connected` (connect HeyReach on the dashboard first) or `invalid_key` (HeyReach rejected the stored key).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "502": {
            "description": "HeyReach did not answer, or refused the request.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/integrations/heyreach/send": {
      "post": {
        "tags": [
          "Integrations"
        ],
        "summary": "Send leads to a HeyReach campaign",
        "operationId": "sendToHeyReach",
        "description": "Add leads to a HeyReach campaign, where its LinkedIn outreach starts. Send `leadIds` (from GET /api/v1/leads or /api/v1/agent/leads; with `agentId` each Agent Lead also carries its ICP % and signal) or `people` (influencers found by Discover, which have no lead row: their profile URL, name, title, company and country are sent as given), at most 1,000 a call. Only ENRICHED leads are sent; a lead with no LinkedIn profile URL is skipped (`skippedNoLinkedin`); a person is never sent twice to the same campaign (`alreadyInCampaign`); a paused campaign is never resumed, and a finished one is never restarted. HeyReach receives name, job title, company, country, work email (when found), the LinkedIn profile URL and the headline (as `summary`), plus custom fields `icp_percent`, `signal`, `engaged_post_url`, `comment_text` and `source_name` where known. Charges no credits.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/HeyReachSendRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "What was sent.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HeyReachSendResult"
                }
              }
            }
          },
          "400": {
            "description": "A malformed body, or code `campaign_closed` / `no_senders`: the campaign cannot take leads now.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "None of the leads is an enriched lead on this team, or code `agent_not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "409": {
            "description": "Code `not_connected` or `invalid_key`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "502": {
            "description": "HeyReach stopped part-way: `partial` says what was already added.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/sources/{id}/heyreach": {
      "get": {
        "tags": [
          "Integrations"
        ],
        "summary": "Read a source's HeyReach auto-push, by source id",
        "operationId": "getSourceHeyReach",
        "description": "The HeyReach auto-push of any tracked source (person, company page, tracked post or keyword search): the campaign its new leads go to, whether new leads go automatically, its filter rows and whether the worker has paused it. `heyreach` is null when the source has none. An agent's auto-push is `heyreach` on GET /api/v1/agent. Charges nothing.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The SOURCE id, as GET /api/v1/sources returns it in each source's `id`: a person, a company page, a tracked post or a keyword search."
          }
        ],
        "responses": {
          "200": {
            "description": "The source and its auto-push.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SourceHeyReachConfig"
                }
              }
            }
          },
          "400": {
            "description": "Not a source id.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "No such source on this team.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "put": {
        "tags": [
          "Integrations"
        ],
        "summary": "Set a source's HeyReach auto-push, by source id",
        "operationId": "setSourceHeyReach",
        "description": "Send a source's new leads to a HeyReach campaign automatically. `autoSend` (default true) sends each new lead that passes `filters` once it is enriched, from the moment it is switched on; `pushHistoric: true` also sends the leads already found that pass the filters, once. The filters are the leads tables' Filters rows; a source's leads have no ICP %, signal or engagement count, so those columns are refused (they are the Engagement Agent's ranking). The campaign must exist and be able to take leads. A body of `null`, or `{ \"campaignId\": null }`, removes the auto-push and drops leads still queued for it. The worker pauses an auto-push when HeyReach rejects the key (`invalid_key`) or the campaign can no longer take leads (`campaign_closed`); saving a campaign starts it again. Only ENRICHED leads are sent; a lead with no LinkedIn profile URL is skipped (`skippedNoLinkedin`); a person is never sent twice to the same campaign (`alreadyInCampaign`); a paused campaign is never resumed, and a finished one is never restarted.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The SOURCE id, as GET /api/v1/sources returns it in each source's `id`: a person, a company page, a tracked post or a keyword search."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/HeyReachAutoPushInput"
                  },
                  {
                    "type": "null"
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The source and its saved auto-push; `historic` when pushHistoric was sent.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SourceHeyReachConfig"
                }
              }
            }
          },
          "400": {
            "description": "A malformed body, a refused filter column, or code `campaign_closed`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "No such source, or code `campaign_not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "409": {
            "description": "Code `not_connected` or `invalid_key`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "description": "Code `auto_push_unavailable`: this server does not have auto-push yet.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/credits": {
      "get": {
        "tags": [
          "Credits"
        ],
        "summary": "Get credit balance",
        "operationId": "getCredits",
        "description": "Read the authenticated team's enriching credit balance (balance, used, limit, remaining, plan, and the reset dates). Enrichment charges one credit per newly enriched person per source; repeat engagement rows are free; already-enriched, failed, and empty leads are not charged. Read-only, synchronous \u2014 returns the balance directly, not a job.",
        "responses": {
          "200": {
            "description": "The team's enriching credit balance.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreditsBalance"
                },
                "examples": {
                  "balance": {
                    "value": {
                      "balance": 58750,
                      "used": 1250,
                      "limit": 60000,
                      "remaining": 58750,
                      "plan": "pro",
                      "resetAt": "2026-07-01T00:00:00Z",
                      "nextReset": "2026-08-01T00:00:00Z"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "The team has no active enriching credit plan.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/SubscriptionInactive"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/v1/credits/usage": {
      "get": {
        "tags": [
          "Credits"
        ],
        "summary": "Get credit usage breakdown",
        "operationId": "getCreditsUsage",
        "description": "Read a dated and by-source breakdown of enriching credits charged. Returns totalCharged plus bySource (e.g. api_enrich_profile from the Public API vs worker_sync from the automated sync), byDate (per UTC day) and bySourceId (per tracked source). RECONCILE WITH bySourceId: it sums to totalCharged even when the charged leads are no longer readable, because a source untracked inside the window is still reported there, named and marked status \"inactive\". bySource says which SYSTEM charged, never which source, so it cannot close a gap between credits and visible leads on its own. Defaults to the current billing period; pass from/to (ISO 8601) to widen or narrow the window. Read-only, synchronous. Reconcile against the balance from getCredits.",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "ISO 8601 start timestamp (default: current billing-period start)."
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "ISO 8601 end timestamp (default: now)."
          }
        ],
        "responses": {
          "200": {
            "description": "Charged-credit breakdown for the window.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreditsUsage"
                },
                "examples": {
                  "usage": {
                    "value": {
                      "from": "2026-07-01T00:00:00Z",
                      "to": "2026-07-18T00:00:00Z",
                      "totalCharged": 1250,
                      "bySource": [
                        {
                          "source": "worker_sync",
                          "credits": 1180
                        },
                        {
                          "source": "api_enrich_profile",
                          "credits": 70
                        }
                      ],
                      "byDate": [
                        {
                          "date": "2026-07-17",
                          "credits": 42
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid from/to timestamp.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/SubscriptionInactive"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/v1/keys": {
      "get": {
        "tags": [
          "Account"
        ],
        "summary": "List API keys and quota",
        "operationId": "listApiKeys",
        "description": "List the team's ACTIVE API keys (masked \u2014 never the plaintext or hash) plus the per-team quota. A valid key manages its OWN team's keys. Read-only. Returns { keys: [{ id, name, keyPrefix, createdAt, lastUsedAt }], used, max, remaining }.",
        "responses": {
          "200": {
            "description": "The team's active keys and quota.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "keys": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string"
                          },
                          "keyPrefix": {
                            "type": "string",
                            "description": "Masked display form, e.g. cs_af735\u2026e593. The plaintext is never returned by list."
                          },
                          "createdAt": {
                            "type": "string",
                            "format": "date-time"
                          },
                          "lastUsedAt": {
                            "type": "string",
                            "format": "date-time",
                            "nullable": true
                          }
                        }
                      }
                    },
                    "used": {
                      "type": "integer",
                      "description": "Active keys in use."
                    },
                    "max": {
                      "type": "integer",
                      "description": "Maximum active keys per team."
                    },
                    "remaining": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/SubscriptionInactive"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "post": {
        "tags": [
          "Account"
        ],
        "summary": "Create an API key",
        "operationId": "createApiKey",
        "description": "Create a new named API key for the team. **The plaintext `apiKey` is returned ONCE in this response and is never retrievable again** \u2014 store it immediately. Capped at the per-team `max` (see listApiKeys); at the cap this returns 409 `max_keys_reached`. Keys are SHA-256 hashed at rest.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 60,
                    "description": "A label for the key, e.g. \"CI\" or \"Zapier\"."
                  }
                }
              },
              "examples": {
                "ci": {
                  "value": {
                    "name": "CI"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Key created; plaintext returned once.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "apiKey": {
                      "type": "string",
                      "description": "The plaintext cs_ key \u2014 shown ONCE, store it now."
                    },
                    "key": {
                      "type": "object",
                      "properties": {
                        "id": {
                          "type": "string"
                        },
                        "name": {
                          "type": "string"
                        },
                        "keyPrefix": {
                          "type": "string",
                          "description": "Masked display form, e.g. cs_af735\u2026e593. The plaintext is never returned by list."
                        },
                        "createdAt": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "lastUsedAt": {
                          "type": "string",
                          "format": "date-time",
                          "nullable": true
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Missing or too-long name.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/SubscriptionInactive"
          },
          "409": {
            "description": "At the per-team key cap (code max_keys_reached); revoke one to create another.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/keys/{id}": {
      "delete": {
        "tags": [
          "Account"
        ],
        "summary": "Revoke an API key",
        "operationId": "revokeApiKey",
        "description": "Revoke an API key by id (soft delete). It stops authenticating immediately; the audit row is kept. Team-scoped \u2014 a team can only revoke its own keys. Get the id from listApiKeys.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Key id from listApiKeys."
          }
        ],
        "responses": {
          "200": {
            "description": "Key revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "revoked": {
                      "type": "boolean"
                    },
                    "id": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid key id.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/SubscriptionInactive"
          },
          "404": {
            "description": "Key not found (wrong id, another team's, or already revoked).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/profile/{username}/sync": {
      "get": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Get sync status for a tracked profile",
        "operationId": "getProfileSyncStatus",
        "description": "Progress of the background capture sync for a tracked personal LinkedIn profile. Tracking (saveTrackedProfile=true) queues a staged sync \u2014 collecting_posts \u2192 collecting_engagements \u2192 enriching \u2192 completed \u2014 and the jobId returned by the enrichment endpoints covers ONLY the profile enrichment, not this sync. Poll this to tell queued / in progress / complete-with-0-leads / failed apart, and stop when `isFinal` is true. THE LIFECYCLE INCLUDES ENRICHMENT, AND SO DOES `isFinal`: capture writes the engagement records, then new people for this source are enriched with firmographics for one credit each; their repeat engagement rows are free, and that second half normally runs on well after the capture run itself has ended. `state` therefore stays `enriching` and `isFinal` stays false while leads are still being enriched and billed \u2014 read `enrichment` for the pending / completed / failed / skipped breakdown, and `capture` when all you need to know is that collection finished. Duration is not fixed: the sync is queued, and how long it takes depends on current API load and how many posts and engagements the source has. Leads appear progressively while it runs.",
        "parameters": [
          {
            "name": "username",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/LinkedInUsername"
            },
            "description": "Public identifier of the tracked personal profile (not a full URL), e.g. demo-profile."
          }
        ],
        "responses": {
          "200": {
            "description": "Current sync state for the tracked source.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "username",
                    "profileType",
                    "state",
                    "isFinal",
                    "capture",
                    "enrichment",
                    "progress"
                  ],
                  "properties": {
                    "username": {
                      "type": "string"
                    },
                    "profileType": {
                      "type": "string",
                      "enum": [
                        "person",
                        "company"
                      ]
                    },
                    "syncId": {
                      "type": "string",
                      "format": "uuid",
                      "nullable": true,
                      "description": "The background sync run. Null when no sync has ever been queued."
                    },
                    "state": {
                      "type": "string",
                      "enum": [
                        "queued",
                        "collecting_posts",
                        "collecting_engagements",
                        "enriching",
                        "completed",
                        "failed",
                        "paused"
                      ],
                      "description": "Stage of the source's background lifecycle, capture AND enrichment. Mirrors the dashboard's stages: collecting_posts \u2192 collecting_engagements \u2192 enriching. `enriching` also covers enrichment that continues after the capture run has finished \u2014 the usual case, since enrichment runs off the capture job \u2014 so a source stays `enriching` until nothing is left in `enrichment.pending`. `paused` means enriching credits ran out; it resumes automatically when credits return."
                    },
                    "description": {
                      "type": "string",
                      "description": "One-line, human-readable explanation of `state`."
                    },
                    "isFinal": {
                      "type": "boolean",
                      "description": "True when the WHOLE lifecycle is over \u2014 capture finished and no lead is still queued for enrichment \u2014 so nothing further will change without a new run: stop polling. It is NOT set while enrichment is still running and still spending credits. For capture alone, read `capture.isFinal`."
                    },
                    "queuedAt": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true
                    },
                    "startedAt": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true
                    },
                    "updatedAt": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true
                    },
                    "completedAt": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true,
                      "description": "When the run finished, capture and enrichment both. Null whenever `isFinal` is false, so it can never read as \"done\" over leads that are still being enriched. For the capture run's own stamp, read `capture.completedAt`."
                    },
                    "capture": {
                      "type": "object",
                      "description": "The capture half on its own: posts walked and engagement records written, saying nothing about enriching them. `state`, `isFinal` and `completedAt` are exactly what the top-level fields of the same names reported before they were corrected to describe the whole lifecycle, so a caller that only ever needed \"collection finished\" reads `capture.isFinal` here. `stoppedBy`, `creditsSpent`, `leadRowsCaptured` and `coverage` beside them say WHY the run ended where it did, what it was charged, how many lead rows it wrote and how much of what the provider declared it holds \u2014 the answer to \"why did this source come back small\", which progress counts alone could never give.",
                      "properties": {
                        "state": {
                          "type": "string",
                          "enum": [
                            "queued",
                            "collecting_posts",
                            "collecting_engagements",
                            "enriching",
                            "completed",
                            "failed",
                            "paused"
                          ],
                          "description": "The capture run's own stage."
                        },
                        "isFinal": {
                          "type": "boolean",
                          "description": "True once the capture run reached a terminal state, whatever enrichment is still doing."
                        },
                        "completedAt": {
                          "type": "string",
                          "format": "date-time",
                          "nullable": true,
                          "description": "When the capture run finished. Enrichment normally continues past this moment."
                        },
                        "stoppedBy": {
                          "type": "string",
                          "nullable": true,
                          "enum": [
                            "exhausted",
                            "credits",
                            "capture_empty",
                            "provider_limit",
                            null
                          ],
                          "description": "WHY THE CAPTURE RUN ENDED, or `null`. `credits` means the source's own `creditCapPerSync` was reached and the run stopped THERE \u2014 there was more to collect, and raising the limit would get it. `exhausted` means the run collected every engagement it found, so a bigger limit would change nothing. `capture_empty` means it collected POSTS and captured NOBODY from them \u2014 not a quiet week and not an ending anybody chose: between 12 and 14 September 2026 the upstream engager endpoints answered 200 with no rows for three days and every sync reported `completed` with `error: null` while leads/day went 3,150 to 0. `errorCode` carries the same marker and `error` the sentence \"Harvested N posts, captured nobody.\" A sync that collected NO posts never reports it. `provider_limit` means the DATA PROVIDER stopped serving a post's reactions with at least one full page (50) of them still declared, and no cap was the reason: the capture holds materially fewer people than the post declares and NO LIMIT WILL GET THE REST, because the provider will not serve them. Measured 29 September 2026: a post declaring 3,490 reactions stopped at about 1,100 people (22 pages of ~50, then only empty pages, every one still declaring 3,490). `coverage` beside it gives the declared and captured counts. The rule counts the rows the provider SERVED, reactions it sends with no identity included, against the declared total, so a post served in full still ends `exhausted` (418 declared, 404 identifiable and 14 anonymous is complete); it is the rule POST /api/v1/post/reactions uses for `exhausted: false`, so the two never disagree about one post. A run the credit cap stopped reports `credits`, never this. Before this field the two were the SAME RESPONSE: \"collected 100 because that was everything\" and \"collected 100 because 100 was the cap\" both arrived as progress counts with nothing to tell them apart. THE WORDS ARE `lastRun.stoppedBy`'s ON GET /api/v1/sources, deliberately \u2014 the keyword side closed this same gap first, and a second vocabulary for one question is how a caller ends up writing two branches for one fact. \u26a0 `null` IS FOUR DIFFERENT THINGS AND ALL FOUR ARE HONEST: the first sync has not finished yet; the run FAILED (neither word is true of a run that broke \u2014 read `state` and `errorCode`); the source was UNTRACKED mid-run, an ending this vocabulary has no word for and which the TOP-LEVEL `stoppedBy` reports as `untracked`; or this is a KEYWORD search, whose sweep records its ending on `lastRun.stoppedBy` at GET /api/v1/sources and never here. The TOP-LEVEL `stoppedBy` on this same response names the SAME ending, spelling the cap `credit_cap` and an untracked run `untracked`."
                        },
                        "creditsSpent": {
                          "type": "integer",
                          "nullable": true,
                          "description": "WHAT THIS RUN WAS CHARGED, in credits, or `null` \u2014 the same ledger GET /api/v1/credits/usage totals, attributed to this run: the credits charged for the leads it created (one per NEW person per source \u2014 a repeat engager's row, and a lead whose enrichment failed, cost nothing), the posts a posts-only watch bought (one credit each), or, for a keyword search, what its sweep spent (the number `lastRun.creditsSpent` reports). \u26a0 CHANGED IN THIS RELEASE: it used to be the lead-ROW count, which read as an overspend (46 rows beside a `creditCap` of 30 that charged 30) and as free for a paid posts-only run (0). That count is now `leadRowsCaptured`. Enrichment charges a run's leads AFTER the capture ends, so while `isFinal` is false this is the charge SO FAR; it is settled when `isFinal` is true. PRESENT ON A FAILED RUN TOO, unlike `stoppedBy`: the leads a run wrote before it broke are still enriched and charged. `null` until the capture reaches a terminal state, and `null` \u2014 never a guessed `0` \u2014 when the charge could not be read. A `0` is a measurement."
                        },
                        "leadRowsCaptured": {
                          "type": "integer",
                          "nullable": true,
                          "description": "The lead ROWS this run wrote \u2014 what `creditsSpent` reported before this release. NOT what it cost: billing charges once per new person per source, so a repeat engager writes a row for free and a posts-only watch writes none. `creditCapPerSync` caps these rows. `null` until the capture reaches a terminal state and for a run that predates the counter; present on a failed run too. A `0` is a measurement."
                        },
                        "coverage": {
                          "type": "object",
                          "nullable": true,
                          "required": [
                            "declared",
                            "captured"
                          ],
                          "description": "DECLARED BESIDE CAPTURED: the reactions + comments the provider declared on the posts this run swept, and the leads this source now holds on those same posts \u2014 so a post the provider cut off reads `{\"declared\": 3690, \"captured\": 1147}` instead of being left to be inferred. `captured` is cumulative (a re-sync that adds 5 people to a post it already holds 1,045 of reports 1,050) and counts PEOPLE, deduplicated, while `declared` counts engagements, so even a post captured in full reads a little under (reactions with no identity, company pages, one person who both reacted and commented). The gap that matters is the one `stoppedBy: \"provider_limit\"` names. `null` until the capture is over, for a run recorded before this field, for a keyword search or a posts-only watch (which record no engagement coverage), and whenever either number is missing \u2014 half a pair is never served.",
                          "properties": {
                            "declared": {
                              "type": "integer",
                              "minimum": 0
                            },
                            "captured": {
                              "type": "integer",
                              "minimum": 0
                            }
                          }
                        }
                      }
                    },
                    "enrichment": {
                      "type": "object",
                      "description": "The source's leads by enrichment outcome. The four buckets partition `total`, so the gap between captured and enriched leads is always attributable. THIS IS WHERE THE CREDITS GO: one credit per newly enriched person per source; repeat engagement rows are free, so spend continues after capture completes. A run's billing is settled and its downstream processing done when `pending` reaches 0 \u2014 the same moment `isFinal` becomes true, `leadsReady` flips, and the run's `lead.detected` webhooks and provider integrations fire.",
                      "properties": {
                        "total": {
                          "type": "integer",
                          "description": "All leads held for this source, across runs. The same number as `progress.leadsTotal`."
                        },
                        "pending": {
                          "type": "integer",
                          "description": "Queued or in flight: work still owed, and still to be billed. Above 0 means this source is not finished, whatever the capture run says."
                        },
                        "completed": {
                          "type": "integer",
                          "description": "Enriched: carries firmographics. This is a row count, not billed credits; repeats of a person already charged for this source are free. For a source in raw mode (`enrichLeads: false`) it counts the leads captured raw and charged instead \u2014 done, never enriched, carrying no firmographics, and read with GET /api/v1/leads/raw."
                        },
                        "failed": {
                          "type": "integer",
                          "description": "Enrichment attempts exhausted. Terminal and never charged \u2014 these are reported rather than left holding the lifecycle open."
                        },
                        "skipped": {
                          "type": "integer",
                          "description": "Terminal with no data to add: the provider returned nothing for the person, or the team has no chargeable enriching plan. Never charged."
                        }
                      }
                    },
                    "progress": {
                      "type": "object",
                      "properties": {
                        "postsCollected": {
                          "type": "integer",
                          "description": "Posts found on the source in this run."
                        },
                        "engagementsCaptured": {
                          "type": "integer",
                          "description": "Engagements (likes + comments) captured in this run."
                        },
                        "leadsTotal": {
                          "type": "integer",
                          "description": "All leads currently held for this source, across runs."
                        },
                        "leadsEnriched": {
                          "type": "integer",
                          "description": "Of those, how many carry enriched firmographics. Unchanged, and equal to `enrichment.completed`; `enrichment` accounts for the rest. For a source in raw mode (`enrichLeads: false`) it counts the leads captured raw and charged, which carry none."
                        }
                      }
                    },
                    "stoppedBy": {
                      "type": "string",
                      "nullable": true,
                      "enum": [
                        "credit_cap",
                        "exhausted",
                        "capture_empty",
                        "provider_limit",
                        "untracked",
                        null
                      ],
                      "description": "HOW THIS RUN ENDED \u2014 the SAME ending `capture.stoppedBy` names, so the two fields never disagree. `credit_cap` means the source's own per-sync credit limit (tracked_profiles.credit_cap, reported as `creditCapPerSync` on GET /api/v1/sources) was reached and the run stopped there \u2014 an ORDINARY ending, not a failure: `state` stays `completed`, `errorCode` is null, nothing broke. It is the ending `capture.stoppedBy` spells `credits`: both spellings were published first and callers branch on each, so neither is renamed. `exhausted`, `capture_empty` and `provider_limit` mean exactly what they mean on `capture.stoppedBy`. `untracked` means the source was untracked while the run was in flight and the sweep was abandoned \u2014 an ending the capture vocabulary has no word for, so `capture.stoppedBy` is `null` beside it. `null` means there is no ending to name: no run has finished yet, the run FAILED (read `state` and `errorCode`), or this is a KEYWORD sweep, which records how it ended on keyword_searches.last_run_stopped_by and GET /api/v1/sources reports as `lastRun.stoppedBy`. \u26a0 CHANGED IN THIS RELEASE: this field used to be `null` for every ordinary run \u2014 beside `capture.stoppedBy: \"exhausted\"`, and beside a post the provider had cut off at a third of its reactions \u2014 so a caller reading only this field saw no ending at all. \u26a0\ufe0f THOSE TWO FIELDS ARE NOT THE SAME LIST: `lastRun.stoppedBy` answers \"how did this keyword sweep end\" (budget / credits / exhausted / error / ai_error / team_cap / lead_cap); this one answers how a person, company-page or tracked-post CAPTURE ended. Read from the same job row the rest of this response describes, so it is about the CURRENT run and not about the source's history. PER SYNC, EVERY SYNC: it bounds EACH sync of that one person, company page or post \u2014 the capture cap counts lead rows, while billing charges once per new person per source \u2014 not the first pull only and not the life of the source, and there is NO ESTIMATE, NO CONFIRMATION GATE AND NO TEAM CEILING behind it: nothing prices a sync before you set the cap, raising or lowering it never needs `confirmSpend`, and the team's `dailyCeiling` does not count a credit of it. \u26a0\ufe0f A KEYWORD SEARCH'S `creditCap` IS THE OTHER FIELD AND HAS ALL THREE: it is the daily bound on a RECURRING SWEEP, POST /api/v1/keyword/estimate prices it as `estimatedDailyMax`, `confirmSpend` gates a create or a raise with a `409 spend_confirmation_required`, and the team's `dailyCeiling` stops it \u2014 and `dailyCeiling` COUNTS KEYWORD SPEND ONLY, so no number of profile syncs can ever reach it."
                    },
                    "lastSyncedAt": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true
                    },
                    "nextSyncAt": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true,
                      "description": "When the next scheduled sync is due (daily cadence). null while a sync is currently running \u2014 there is no next run scheduled until the active one finishes; use `state`/`isFinal` to see it is in progress. null for a source that is not ACTIVE \u2014 untracked, or paused after repeated not-found errors \u2014 always: nothing will run it (untracking parks its schedule, and a stored value is never served for a source that is not active)."
                    },
                    "leadsReady": {
                      "type": "boolean",
                      "description": "True once leads are ready to read."
                    },
                    "error": {
                      "type": "string",
                      "nullable": true,
                      "description": "Human-readable failure reason when `state` is `failed`. Owned Cornersight text - it never contains the upstream data provider's raw response. Wording may change; branch on `errorCode`."
                    },
                    "lifecycleEvent": {
                      "type": "object",
                      "description": "Whether a sync.completed / sync.failed callback was attempted for this source, and what became of it. `enabled` is true only when syncEvents is on AND a webhook URL is set. `lastDelivery` is the most recent delivery recorded for this source, or null when none has ever been enqueued \u2014 so `enabled: true` with `lastDelivery: null` after a finished run means nothing was attempted, which is a different problem from an endpoint that rejected the payload. A delivery enqueued but not yet attempted reads `status: \"queued\"` with its `queuedAt`, and `stalled: true` once it has waited five minutes without a first attempt. An explicit lead push is reported here too when it is the newest delivery on the source, so `lastDelivery` can be set while `enabled` is false. Read-only; a source with lifecycle events off and no push reports `{ enabled: false, lastDelivery: null }`.",
                      "properties": {
                        "enabled": {
                          "type": "boolean",
                          "description": "Is this source configured to emit lifecycle events at all?"
                        },
                        "lastDelivery": {
                          "type": "object",
                          "nullable": true,
                          "description": "The newest delivery recorded for this source: an outbox row, or the newest explicit lead push when that is more recent. `event` (sync.completed | sync.failed | spend.cap_reached | spend.ceiling_reached | lead.detected \u2014 the outbox carries the first four, so a `lastDelivery` naming a spend event is not a stray: it is the most recent attempt for this source, whichever event it was; lead.detected means an explicit push), `syncId` (which run it describes \u2014 compare against `syncId` above to tell this run from the previous one; null for a push), `status` (queued = enqueued and not yet attempted | pending = attempted, waiting for its next retry | delivering | delivered | failed \u2014 and held, for a push whose every lead the webhook's icpOnly filter took), `attempts`, `maxAttempts` (for a push: its leads attempted so far, and its leads in all), `responseStatus` (the HTTP status your endpoint returned on the last attempt; null on a transport failure and for a push), `lastError`, `queuedAt`, `deliveredAt`, `nextAttemptAt` (null once the delivery is terminal), `stalled` (true when still queued, never attempted, five minutes or more after `queuedAt` \u2014 a delay on Cornersight's side, not your endpoint), and for a push `pushId` (for GET /api/v1/push/{pushId}) and `counts` (pending, delivering, delivered, failed, held)."
                        }
                      }
                    },
                    "errorCode": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "enum": [
                        "insufficient_enriching_credits",
                        "not_found",
                        "page_out_of_range",
                        "invalid_request",
                        "provider_rate_limited",
                        "provider_access_denied",
                        "provider_unavailable",
                        "internal_error",
                        "ai_error",
                        "search_term_rejected",
                        null
                      ],
                      "description": "Stable, machine-readable reason for the failure, or `null` when the sync has not failed. The values JobStatus.errorCode uses, plus two that only a KEYWORD sweep produces and that are the CUSTOMER'S to fix: `ai_error` \u2014 the search's AI filter failed on the team's own AI setup (a model the provider does not recognise, a revoked key, an exhausted quota); `error` says which and what to change, in the same words as `lastRun.reason`, and `lastRun.aiErrorCode` carries the fine-grained code. `search_term_rejected` \u2014 the data provider refused one of the search's terms (HTTP 400); `error` names the term. The same term is refused on every run, so edit the search's keywords \u2014 retrying will not help. Before this release both were reported as `internal_error` (\"retrying may succeed\"), which told the customer the problem was Cornersight's."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/MissingUsername"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TrackedProfileDeleteForbidden"
          },
          "404": {
            "description": "No such tracked profile for this team.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/v1/company/{username}/sync": {
      "get": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Get sync status for a tracked company page",
        "operationId": "getCompanySyncStatus",
        "description": "Progress of the background capture sync for a tracked LinkedIn company page. Tracking (saveTrackedProfile=true) queues a staged sync \u2014 collecting_posts \u2192 collecting_engagements \u2192 enriching \u2192 completed \u2014 and the jobId returned by the enrichment endpoints covers ONLY the profile enrichment, not this sync. Poll this to tell queued / in progress / complete-with-0-leads / failed apart, and stop when `isFinal` is true. THE LIFECYCLE INCLUDES ENRICHMENT, AND SO DOES `isFinal`: capture writes the engagement records, then new people for this source are enriched with firmographics for one credit each; their repeat engagement rows are free, and that second half normally runs on well after the capture run itself has ended. `state` therefore stays `enriching` and `isFinal` stays false while leads are still being enriched and billed \u2014 read `enrichment` for the pending / completed / failed / skipped breakdown, and `capture` when all you need to know is that collection finished. Duration is not fixed: the sync is queued, and how long it takes depends on current API load and how many posts and engagements the source has. Leads appear progressively while it runs.",
        "parameters": [
          {
            "name": "username",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/LinkedInUsername"
            },
            "description": "Public identifier of the tracked personal profile (not a full URL), e.g. demo-profile."
          }
        ],
        "responses": {
          "200": {
            "description": "Current sync state for the tracked source.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "username",
                    "profileType",
                    "state",
                    "isFinal",
                    "capture",
                    "enrichment",
                    "progress"
                  ],
                  "properties": {
                    "username": {
                      "type": "string"
                    },
                    "profileType": {
                      "type": "string",
                      "enum": [
                        "person",
                        "company"
                      ]
                    },
                    "syncId": {
                      "type": "string",
                      "format": "uuid",
                      "nullable": true,
                      "description": "The background sync run. Null when no sync has ever been queued."
                    },
                    "state": {
                      "type": "string",
                      "enum": [
                        "queued",
                        "collecting_posts",
                        "collecting_engagements",
                        "enriching",
                        "completed",
                        "failed",
                        "paused"
                      ],
                      "description": "Stage of the source's background lifecycle, capture AND enrichment. Mirrors the dashboard's stages: collecting_posts \u2192 collecting_engagements \u2192 enriching. `enriching` also covers enrichment that continues after the capture run has finished \u2014 the usual case, since enrichment runs off the capture job \u2014 so a source stays `enriching` until nothing is left in `enrichment.pending`. `paused` means enriching credits ran out; it resumes automatically when credits return."
                    },
                    "description": {
                      "type": "string",
                      "description": "One-line, human-readable explanation of `state`."
                    },
                    "isFinal": {
                      "type": "boolean",
                      "description": "True when the WHOLE lifecycle is over \u2014 capture finished and no lead is still queued for enrichment \u2014 so nothing further will change without a new run: stop polling. It is NOT set while enrichment is still running and still spending credits. For capture alone, read `capture.isFinal`."
                    },
                    "queuedAt": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true
                    },
                    "startedAt": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true
                    },
                    "updatedAt": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true
                    },
                    "completedAt": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true,
                      "description": "When the run finished, capture and enrichment both. Null whenever `isFinal` is false, so it can never read as \"done\" over leads that are still being enriched. For the capture run's own stamp, read `capture.completedAt`."
                    },
                    "capture": {
                      "type": "object",
                      "description": "The capture half on its own: posts walked and engagement records written, saying nothing about enriching them. `state`, `isFinal` and `completedAt` are exactly what the top-level fields of the same names reported before they were corrected to describe the whole lifecycle, so a caller that only ever needed \"collection finished\" reads `capture.isFinal` here. `stoppedBy`, `creditsSpent`, `leadRowsCaptured` and `coverage` beside them say WHY the run ended where it did, what it was charged, how many lead rows it wrote and how much of what the provider declared it holds \u2014 the answer to \"why did this source come back small\", which progress counts alone could never give.",
                      "properties": {
                        "state": {
                          "type": "string",
                          "enum": [
                            "queued",
                            "collecting_posts",
                            "collecting_engagements",
                            "enriching",
                            "completed",
                            "failed",
                            "paused"
                          ],
                          "description": "The capture run's own stage."
                        },
                        "isFinal": {
                          "type": "boolean",
                          "description": "True once the capture run reached a terminal state, whatever enrichment is still doing."
                        },
                        "completedAt": {
                          "type": "string",
                          "format": "date-time",
                          "nullable": true,
                          "description": "When the capture run finished. Enrichment normally continues past this moment."
                        },
                        "stoppedBy": {
                          "type": "string",
                          "nullable": true,
                          "enum": [
                            "exhausted",
                            "credits",
                            "capture_empty",
                            "provider_limit",
                            null
                          ],
                          "description": "WHY THE CAPTURE RUN ENDED, or `null`. `credits` means the source's own `creditCapPerSync` was reached and the run stopped THERE \u2014 there was more to collect, and raising the limit would get it. `exhausted` means the run collected every engagement it found, so a bigger limit would change nothing. `capture_empty` means it collected POSTS and captured NOBODY from them \u2014 not a quiet week and not an ending anybody chose: between 12 and 14 September 2026 the upstream engager endpoints answered 200 with no rows for three days and every sync reported `completed` with `error: null` while leads/day went 3,150 to 0. `errorCode` carries the same marker and `error` the sentence \"Harvested N posts, captured nobody.\" A sync that collected NO posts never reports it. `provider_limit` means the DATA PROVIDER stopped serving a post's reactions with at least one full page (50) of them still declared, and no cap was the reason: the capture holds materially fewer people than the post declares and NO LIMIT WILL GET THE REST, because the provider will not serve them. Measured 29 September 2026: a post declaring 3,490 reactions stopped at about 1,100 people (22 pages of ~50, then only empty pages, every one still declaring 3,490). `coverage` beside it gives the declared and captured counts. The rule counts the rows the provider SERVED, reactions it sends with no identity included, against the declared total, so a post served in full still ends `exhausted` (418 declared, 404 identifiable and 14 anonymous is complete); it is the rule POST /api/v1/post/reactions uses for `exhausted: false`, so the two never disagree about one post. A run the credit cap stopped reports `credits`, never this. Before this field the two were the SAME RESPONSE: \"collected 100 because that was everything\" and \"collected 100 because 100 was the cap\" both arrived as progress counts with nothing to tell them apart. THE WORDS ARE `lastRun.stoppedBy`'s ON GET /api/v1/sources, deliberately \u2014 the keyword side closed this same gap first, and a second vocabulary for one question is how a caller ends up writing two branches for one fact. \u26a0 `null` IS FOUR DIFFERENT THINGS AND ALL FOUR ARE HONEST: the first sync has not finished yet; the run FAILED (neither word is true of a run that broke \u2014 read `state` and `errorCode`); the source was UNTRACKED mid-run, an ending this vocabulary has no word for and which the TOP-LEVEL `stoppedBy` reports as `untracked`; or this is a KEYWORD search, whose sweep records its ending on `lastRun.stoppedBy` at GET /api/v1/sources and never here. The TOP-LEVEL `stoppedBy` on this same response names the SAME ending, spelling the cap `credit_cap` and an untracked run `untracked`."
                        },
                        "creditsSpent": {
                          "type": "integer",
                          "nullable": true,
                          "description": "WHAT THIS RUN WAS CHARGED, in credits, or `null` \u2014 the same ledger GET /api/v1/credits/usage totals, attributed to this run: the credits charged for the leads it created (one per NEW person per source \u2014 a repeat engager's row, and a lead whose enrichment failed, cost nothing), the posts a posts-only watch bought (one credit each), or, for a keyword search, what its sweep spent (the number `lastRun.creditsSpent` reports). \u26a0 CHANGED IN THIS RELEASE: it used to be the lead-ROW count, which read as an overspend (46 rows beside a `creditCap` of 30 that charged 30) and as free for a paid posts-only run (0). That count is now `leadRowsCaptured`. Enrichment charges a run's leads AFTER the capture ends, so while `isFinal` is false this is the charge SO FAR; it is settled when `isFinal` is true. PRESENT ON A FAILED RUN TOO, unlike `stoppedBy`: the leads a run wrote before it broke are still enriched and charged. `null` until the capture reaches a terminal state, and `null` \u2014 never a guessed `0` \u2014 when the charge could not be read. A `0` is a measurement."
                        },
                        "leadRowsCaptured": {
                          "type": "integer",
                          "nullable": true,
                          "description": "The lead ROWS this run wrote \u2014 what `creditsSpent` reported before this release. NOT what it cost: billing charges once per new person per source, so a repeat engager writes a row for free and a posts-only watch writes none. `creditCapPerSync` caps these rows. `null` until the capture reaches a terminal state and for a run that predates the counter; present on a failed run too. A `0` is a measurement."
                        },
                        "coverage": {
                          "type": "object",
                          "nullable": true,
                          "required": [
                            "declared",
                            "captured"
                          ],
                          "description": "DECLARED BESIDE CAPTURED: the reactions + comments the provider declared on the posts this run swept, and the leads this source now holds on those same posts \u2014 so a post the provider cut off reads `{\"declared\": 3690, \"captured\": 1147}` instead of being left to be inferred. `captured` is cumulative (a re-sync that adds 5 people to a post it already holds 1,045 of reports 1,050) and counts PEOPLE, deduplicated, while `declared` counts engagements, so even a post captured in full reads a little under (reactions with no identity, company pages, one person who both reacted and commented). The gap that matters is the one `stoppedBy: \"provider_limit\"` names. `null` until the capture is over, for a run recorded before this field, for a keyword search or a posts-only watch (which record no engagement coverage), and whenever either number is missing \u2014 half a pair is never served.",
                          "properties": {
                            "declared": {
                              "type": "integer",
                              "minimum": 0
                            },
                            "captured": {
                              "type": "integer",
                              "minimum": 0
                            }
                          }
                        }
                      }
                    },
                    "enrichment": {
                      "type": "object",
                      "description": "The source's leads by enrichment outcome. The four buckets partition `total`, so the gap between captured and enriched leads is always attributable. THIS IS WHERE THE CREDITS GO: one credit per newly enriched person per source; repeat engagement rows are free, so spend continues after capture completes. A run's billing is settled and its downstream processing done when `pending` reaches 0 \u2014 the same moment `isFinal` becomes true, `leadsReady` flips, and the run's `lead.detected` webhooks and provider integrations fire.",
                      "properties": {
                        "total": {
                          "type": "integer",
                          "description": "All leads held for this source, across runs. The same number as `progress.leadsTotal`."
                        },
                        "pending": {
                          "type": "integer",
                          "description": "Queued or in flight: work still owed, and still to be billed. Above 0 means this source is not finished, whatever the capture run says."
                        },
                        "completed": {
                          "type": "integer",
                          "description": "Enriched: carries firmographics. This is a row count, not billed credits; repeats of a person already charged for this source are free. For a source in raw mode (`enrichLeads: false`) it counts the leads captured raw and charged instead \u2014 done, never enriched, carrying no firmographics, and read with GET /api/v1/leads/raw."
                        },
                        "failed": {
                          "type": "integer",
                          "description": "Enrichment attempts exhausted. Terminal and never charged \u2014 these are reported rather than left holding the lifecycle open."
                        },
                        "skipped": {
                          "type": "integer",
                          "description": "Terminal with no data to add: the provider returned nothing for the person, or the team has no chargeable enriching plan. Never charged."
                        }
                      }
                    },
                    "progress": {
                      "type": "object",
                      "properties": {
                        "postsCollected": {
                          "type": "integer",
                          "description": "Posts found on the source in this run."
                        },
                        "engagementsCaptured": {
                          "type": "integer",
                          "description": "Engagements (likes + comments) captured in this run."
                        },
                        "leadsTotal": {
                          "type": "integer",
                          "description": "All leads currently held for this source, across runs."
                        },
                        "leadsEnriched": {
                          "type": "integer",
                          "description": "Of those, how many carry enriched firmographics. Unchanged, and equal to `enrichment.completed`; `enrichment` accounts for the rest. For a source in raw mode (`enrichLeads: false`) it counts the leads captured raw and charged, which carry none."
                        }
                      }
                    },
                    "stoppedBy": {
                      "type": "string",
                      "nullable": true,
                      "enum": [
                        "credit_cap",
                        "exhausted",
                        "capture_empty",
                        "provider_limit",
                        "untracked",
                        null
                      ],
                      "description": "HOW THIS RUN ENDED \u2014 the SAME ending `capture.stoppedBy` names, so the two fields never disagree. `credit_cap` means the source's own per-sync credit limit (tracked_profiles.credit_cap, reported as `creditCapPerSync` on GET /api/v1/sources) was reached and the run stopped there \u2014 an ORDINARY ending, not a failure: `state` stays `completed`, `errorCode` is null, nothing broke. It is the ending `capture.stoppedBy` spells `credits`: both spellings were published first and callers branch on each, so neither is renamed. `exhausted`, `capture_empty` and `provider_limit` mean exactly what they mean on `capture.stoppedBy`. `untracked` means the source was untracked while the run was in flight and the sweep was abandoned \u2014 an ending the capture vocabulary has no word for, so `capture.stoppedBy` is `null` beside it. `null` means there is no ending to name: no run has finished yet, the run FAILED (read `state` and `errorCode`), or this is a KEYWORD sweep, which records how it ended on keyword_searches.last_run_stopped_by and GET /api/v1/sources reports as `lastRun.stoppedBy`. \u26a0 CHANGED IN THIS RELEASE: this field used to be `null` for every ordinary run \u2014 beside `capture.stoppedBy: \"exhausted\"`, and beside a post the provider had cut off at a third of its reactions \u2014 so a caller reading only this field saw no ending at all. \u26a0\ufe0f THOSE TWO FIELDS ARE NOT THE SAME LIST: `lastRun.stoppedBy` answers \"how did this keyword sweep end\" (budget / credits / exhausted / error / ai_error / team_cap / lead_cap); this one answers how a person, company-page or tracked-post CAPTURE ended. Read from the same job row the rest of this response describes, so it is about the CURRENT run and not about the source's history. PER SYNC, EVERY SYNC: it bounds EACH sync of that one person, company page or post \u2014 the capture cap counts lead rows, while billing charges once per new person per source \u2014 not the first pull only and not the life of the source, and there is NO ESTIMATE, NO CONFIRMATION GATE AND NO TEAM CEILING behind it: nothing prices a sync before you set the cap, raising or lowering it never needs `confirmSpend`, and the team's `dailyCeiling` does not count a credit of it. \u26a0\ufe0f A KEYWORD SEARCH'S `creditCap` IS THE OTHER FIELD AND HAS ALL THREE: it is the daily bound on a RECURRING SWEEP, POST /api/v1/keyword/estimate prices it as `estimatedDailyMax`, `confirmSpend` gates a create or a raise with a `409 spend_confirmation_required`, and the team's `dailyCeiling` stops it \u2014 and `dailyCeiling` COUNTS KEYWORD SPEND ONLY, so no number of profile syncs can ever reach it."
                    },
                    "lastSyncedAt": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true
                    },
                    "nextSyncAt": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true,
                      "description": "When the next scheduled sync is due (daily cadence). null while a sync is currently running \u2014 there is no next run scheduled until the active one finishes; use `state`/`isFinal` to see it is in progress. null for a source that is not ACTIVE \u2014 untracked, or paused after repeated not-found errors \u2014 always: nothing will run it (untracking parks its schedule, and a stored value is never served for a source that is not active)."
                    },
                    "leadsReady": {
                      "type": "boolean",
                      "description": "True once leads are ready to read."
                    },
                    "error": {
                      "type": "string",
                      "nullable": true,
                      "description": "Human-readable failure reason when `state` is `failed`. Owned Cornersight text - it never contains the upstream data provider's raw response. Wording may change; branch on `errorCode`."
                    },
                    "lifecycleEvent": {
                      "type": "object",
                      "description": "Whether a sync.completed / sync.failed callback was attempted for this source, and what became of it. `enabled` is true only when syncEvents is on AND a webhook URL is set. `lastDelivery` is the most recent delivery recorded for this source, or null when none has ever been enqueued \u2014 so `enabled: true` with `lastDelivery: null` after a finished run means nothing was attempted, which is a different problem from an endpoint that rejected the payload. A delivery enqueued but not yet attempted reads `status: \"queued\"` with its `queuedAt`, and `stalled: true` once it has waited five minutes without a first attempt. An explicit lead push is reported here too when it is the newest delivery on the source, so `lastDelivery` can be set while `enabled` is false. Read-only; a source with lifecycle events off and no push reports `{ enabled: false, lastDelivery: null }`.",
                      "properties": {
                        "enabled": {
                          "type": "boolean",
                          "description": "Is this source configured to emit lifecycle events at all?"
                        },
                        "lastDelivery": {
                          "type": "object",
                          "nullable": true,
                          "description": "The newest delivery recorded for this source: an outbox row, or the newest explicit lead push when that is more recent. `event` (sync.completed | sync.failed | spend.cap_reached | spend.ceiling_reached | lead.detected \u2014 the outbox carries the first four, so a `lastDelivery` naming a spend event is not a stray: it is the most recent attempt for this source, whichever event it was; lead.detected means an explicit push), `syncId` (which run it describes \u2014 compare against `syncId` above to tell this run from the previous one; null for a push), `status` (queued = enqueued and not yet attempted | pending = attempted, waiting for its next retry | delivering | delivered | failed \u2014 and held, for a push whose every lead the webhook's icpOnly filter took), `attempts`, `maxAttempts` (for a push: its leads attempted so far, and its leads in all), `responseStatus` (the HTTP status your endpoint returned on the last attempt; null on a transport failure and for a push), `lastError`, `queuedAt`, `deliveredAt`, `nextAttemptAt` (null once the delivery is terminal), `stalled` (true when still queued, never attempted, five minutes or more after `queuedAt` \u2014 a delay on Cornersight's side, not your endpoint), and for a push `pushId` (for GET /api/v1/push/{pushId}) and `counts` (pending, delivering, delivered, failed, held)."
                        }
                      }
                    },
                    "errorCode": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "enum": [
                        "insufficient_enriching_credits",
                        "not_found",
                        "page_out_of_range",
                        "invalid_request",
                        "provider_rate_limited",
                        "provider_access_denied",
                        "provider_unavailable",
                        "internal_error",
                        "ai_error",
                        "search_term_rejected",
                        null
                      ],
                      "description": "Stable, machine-readable reason for the failure, or `null` when the sync has not failed. The values JobStatus.errorCode uses, plus two that only a KEYWORD sweep produces and that are the CUSTOMER'S to fix: `ai_error` \u2014 the search's AI filter failed on the team's own AI setup (a model the provider does not recognise, a revoked key, an exhausted quota); `error` says which and what to change, in the same words as `lastRun.reason`, and `lastRun.aiErrorCode` carries the fine-grained code. `search_term_rejected` \u2014 the data provider refused one of the search's terms (HTTP 400); `error` names the term. The same term is refused on every run, so edit the search's keywords \u2014 retrying will not help. Before this release both were reported as `internal_error` (\"retrying may succeed\"), which told the customer the problem was Cornersight's."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/MissingUsername"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TrackedProfileDeleteForbidden"
          },
          "404": {
            "description": "No such tracked company page for this team.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/v1/keyword/{id}/sync": {
      "get": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Check sync status for a tracked keyword search",
        "operationId": "getKeywordSyncStatus",
        "description": "Check a tracked KEYWORD SEARCH's background capture sweep: stage, progress counts, and whether it has finished. Addressed by SOURCE ID, not by keyword text: a keyword search's identifier in GET /api/v1/sources is `id`, while its `username` there is the free-text terms it searches for. A keyword sweep writes a sync_jobs row exactly as a profile sync does, so the state, phase, counters and lead totals here mean the same thing for it as for any other source. THE LIFECYCLE INCLUDES ENRICHMENT, AND SO DOES `isFinal`: capture writes the engagement records, then new people for this source are enriched with firmographics for one credit each; their repeat engagement rows are free, and that second half normally runs on well after the capture run itself has ended. `state` therefore stays `enriching` and `isFinal` stays false while leads are still being enriched and billed \u2014 read `enrichment` for the pending / completed / failed / skipped breakdown, and `capture` when all you need to know is that collection finished.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The keyword search's source id, from GET /api/v1/sources. A keyword search is addressed by id, not by its keyword text."
          }
        ],
        "responses": {
          "200": {
            "description": "Current sync state for the tracked source.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "username",
                    "profileType",
                    "state",
                    "isFinal",
                    "capture",
                    "enrichment",
                    "progress"
                  ],
                  "properties": {
                    "username": {
                      "type": "string"
                    },
                    "profileType": {
                      "type": "string",
                      "enum": [
                        "keyword"
                      ]
                    },
                    "syncId": {
                      "type": "string",
                      "format": "uuid",
                      "nullable": true,
                      "description": "The background sync run. Null when no sync has ever been queued."
                    },
                    "state": {
                      "type": "string",
                      "enum": [
                        "queued",
                        "collecting_posts",
                        "collecting_engagements",
                        "enriching",
                        "completed",
                        "failed",
                        "paused"
                      ],
                      "description": "Stage of the source's background lifecycle, capture AND enrichment. Mirrors the dashboard's stages: collecting_posts \u2192 collecting_engagements \u2192 enriching. `enriching` also covers enrichment that continues after the capture run has finished \u2014 the usual case, since enrichment runs off the capture job \u2014 so a source stays `enriching` until nothing is left in `enrichment.pending`. `paused` means enriching credits ran out; it resumes automatically when credits return."
                    },
                    "description": {
                      "type": "string",
                      "description": "One-line, human-readable explanation of `state`."
                    },
                    "isFinal": {
                      "type": "boolean",
                      "description": "True when the WHOLE lifecycle is over \u2014 capture finished and no lead is still queued for enrichment \u2014 so nothing further will change without a new run: stop polling. It is NOT set while enrichment is still running and still spending credits. For capture alone, read `capture.isFinal`."
                    },
                    "queuedAt": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true
                    },
                    "startedAt": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true
                    },
                    "updatedAt": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true
                    },
                    "completedAt": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true,
                      "description": "When the run finished, capture and enrichment both. Null whenever `isFinal` is false, so it can never read as \"done\" over leads that are still being enriched. For the capture run's own stamp, read `capture.completedAt`."
                    },
                    "capture": {
                      "type": "object",
                      "description": "The capture half on its own: posts walked and engagement records written, saying nothing about enriching them. `state`, `isFinal` and `completedAt` are exactly what the top-level fields of the same names reported before they were corrected to describe the whole lifecycle, so a caller that only ever needed \"collection finished\" reads `capture.isFinal` here. `stoppedBy`, `creditsSpent`, `leadRowsCaptured` and `coverage` beside them say WHY the run ended where it did, what it was charged, how many lead rows it wrote and how much of what the provider declared it holds \u2014 the answer to \"why did this source come back small\", which progress counts alone could never give.",
                      "properties": {
                        "state": {
                          "type": "string",
                          "enum": [
                            "queued",
                            "collecting_posts",
                            "collecting_engagements",
                            "enriching",
                            "completed",
                            "failed",
                            "paused"
                          ],
                          "description": "The capture run's own stage."
                        },
                        "isFinal": {
                          "type": "boolean",
                          "description": "True once the capture run reached a terminal state, whatever enrichment is still doing."
                        },
                        "completedAt": {
                          "type": "string",
                          "format": "date-time",
                          "nullable": true,
                          "description": "When the capture run finished. Enrichment normally continues past this moment."
                        },
                        "stoppedBy": {
                          "type": "string",
                          "nullable": true,
                          "enum": [
                            "exhausted",
                            "credits",
                            "capture_empty",
                            "provider_limit",
                            null
                          ],
                          "description": "WHY THE CAPTURE RUN ENDED, or `null`. `credits` means the source's own `creditCapPerSync` was reached and the run stopped THERE \u2014 there was more to collect, and raising the limit would get it. `exhausted` means the run collected every engagement it found, so a bigger limit would change nothing. `capture_empty` means it collected POSTS and captured NOBODY from them \u2014 not a quiet week and not an ending anybody chose: between 12 and 14 September 2026 the upstream engager endpoints answered 200 with no rows for three days and every sync reported `completed` with `error: null` while leads/day went 3,150 to 0. `errorCode` carries the same marker and `error` the sentence \"Harvested N posts, captured nobody.\" A sync that collected NO posts never reports it. `provider_limit` means the DATA PROVIDER stopped serving a post's reactions with at least one full page (50) of them still declared, and no cap was the reason: the capture holds materially fewer people than the post declares and NO LIMIT WILL GET THE REST, because the provider will not serve them. Measured 29 September 2026: a post declaring 3,490 reactions stopped at about 1,100 people (22 pages of ~50, then only empty pages, every one still declaring 3,490). `coverage` beside it gives the declared and captured counts. The rule counts the rows the provider SERVED, reactions it sends with no identity included, against the declared total, so a post served in full still ends `exhausted` (418 declared, 404 identifiable and 14 anonymous is complete); it is the rule POST /api/v1/post/reactions uses for `exhausted: false`, so the two never disagree about one post. A run the credit cap stopped reports `credits`, never this. Before this field the two were the SAME RESPONSE: \"collected 100 because that was everything\" and \"collected 100 because 100 was the cap\" both arrived as progress counts with nothing to tell them apart. THE WORDS ARE `lastRun.stoppedBy`'s ON GET /api/v1/sources, deliberately \u2014 the keyword side closed this same gap first, and a second vocabulary for one question is how a caller ends up writing two branches for one fact. \u26a0 `null` IS FOUR DIFFERENT THINGS AND ALL FOUR ARE HONEST: the first sync has not finished yet; the run FAILED (neither word is true of a run that broke \u2014 read `state` and `errorCode`); the source was UNTRACKED mid-run, an ending this vocabulary has no word for and which the TOP-LEVEL `stoppedBy` reports as `untracked`; or this is a KEYWORD search, whose sweep records its ending on `lastRun.stoppedBy` at GET /api/v1/sources and never here. The TOP-LEVEL `stoppedBy` on this same response names the SAME ending, spelling the cap `credit_cap` and an untracked run `untracked`."
                        },
                        "creditsSpent": {
                          "type": "integer",
                          "nullable": true,
                          "description": "WHAT THIS RUN WAS CHARGED, in credits, or `null` \u2014 the same ledger GET /api/v1/credits/usage totals, attributed to this run: the credits charged for the leads it created (one per NEW person per source \u2014 a repeat engager's row, and a lead whose enrichment failed, cost nothing), the posts a posts-only watch bought (one credit each), or, for a keyword search, what its sweep spent (the number `lastRun.creditsSpent` reports). \u26a0 CHANGED IN THIS RELEASE: it used to be the lead-ROW count, which read as an overspend (46 rows beside a `creditCap` of 30 that charged 30) and as free for a paid posts-only run (0). That count is now `leadRowsCaptured`. Enrichment charges a run's leads AFTER the capture ends, so while `isFinal` is false this is the charge SO FAR; it is settled when `isFinal` is true. PRESENT ON A FAILED RUN TOO, unlike `stoppedBy`: the leads a run wrote before it broke are still enriched and charged. `null` until the capture reaches a terminal state, and `null` \u2014 never a guessed `0` \u2014 when the charge could not be read. A `0` is a measurement."
                        },
                        "leadRowsCaptured": {
                          "type": "integer",
                          "nullable": true,
                          "description": "The lead ROWS this run wrote \u2014 what `creditsSpent` reported before this release. NOT what it cost: billing charges once per new person per source, so a repeat engager writes a row for free and a posts-only watch writes none. `creditCapPerSync` caps these rows. `null` until the capture reaches a terminal state and for a run that predates the counter; present on a failed run too. A `0` is a measurement."
                        },
                        "coverage": {
                          "type": "object",
                          "nullable": true,
                          "required": [
                            "declared",
                            "captured"
                          ],
                          "description": "DECLARED BESIDE CAPTURED: the reactions + comments the provider declared on the posts this run swept, and the leads this source now holds on those same posts \u2014 so a post the provider cut off reads `{\"declared\": 3690, \"captured\": 1147}` instead of being left to be inferred. `captured` is cumulative (a re-sync that adds 5 people to a post it already holds 1,045 of reports 1,050) and counts PEOPLE, deduplicated, while `declared` counts engagements, so even a post captured in full reads a little under (reactions with no identity, company pages, one person who both reacted and commented). The gap that matters is the one `stoppedBy: \"provider_limit\"` names. `null` until the capture is over, for a run recorded before this field, for a keyword search or a posts-only watch (which record no engagement coverage), and whenever either number is missing \u2014 half a pair is never served.",
                          "properties": {
                            "declared": {
                              "type": "integer",
                              "minimum": 0
                            },
                            "captured": {
                              "type": "integer",
                              "minimum": 0
                            }
                          }
                        }
                      }
                    },
                    "enrichment": {
                      "type": "object",
                      "description": "The source's leads by enrichment outcome. The four buckets partition `total`, so the gap between captured and enriched leads is always attributable. THIS IS WHERE THE CREDITS GO: one credit per newly enriched person per source; repeat engagement rows are free, so spend continues after capture completes. A run's billing is settled and its downstream processing done when `pending` reaches 0 \u2014 the same moment `isFinal` becomes true, `leadsReady` flips, and the run's `lead.detected` webhooks and provider integrations fire.",
                      "properties": {
                        "total": {
                          "type": "integer",
                          "description": "All leads held for this source, across runs. The same number as `progress.leadsTotal`."
                        },
                        "pending": {
                          "type": "integer",
                          "description": "Queued or in flight: work still owed, and still to be billed. Above 0 means this source is not finished, whatever the capture run says."
                        },
                        "completed": {
                          "type": "integer",
                          "description": "Enriched: carries firmographics. This is a row count, not billed credits; repeats of a person already charged for this source are free. For a source in raw mode (`enrichLeads: false`) it counts the leads captured raw and charged instead \u2014 done, never enriched, carrying no firmographics, and read with GET /api/v1/leads/raw."
                        },
                        "failed": {
                          "type": "integer",
                          "description": "Enrichment attempts exhausted. Terminal and never charged \u2014 these are reported rather than left holding the lifecycle open."
                        },
                        "skipped": {
                          "type": "integer",
                          "description": "Terminal with no data to add: the provider returned nothing for the person, or the team has no chargeable enriching plan. Never charged."
                        }
                      }
                    },
                    "progress": {
                      "type": "object",
                      "properties": {
                        "postsCollected": {
                          "type": "integer",
                          "description": "Posts found on the source in this run."
                        },
                        "engagementsCaptured": {
                          "type": "integer",
                          "description": "Engagements (likes + comments) captured in this run."
                        },
                        "leadsTotal": {
                          "type": "integer",
                          "description": "All leads currently held for this source, across runs."
                        },
                        "leadsEnriched": {
                          "type": "integer",
                          "description": "Of those, how many carry enriched firmographics. Unchanged, and equal to `enrichment.completed`; `enrichment` accounts for the rest. For a source in raw mode (`enrichLeads: false`) it counts the leads captured raw and charged, which carry none."
                        }
                      }
                    },
                    "stoppedBy": {
                      "type": "string",
                      "nullable": true,
                      "enum": [
                        "credit_cap",
                        "exhausted",
                        "capture_empty",
                        "provider_limit",
                        "untracked",
                        null
                      ],
                      "description": "HOW THIS RUN ENDED \u2014 the SAME ending `capture.stoppedBy` names, so the two fields never disagree. `credit_cap` means the source's own per-sync credit limit (tracked_profiles.credit_cap, reported as `creditCapPerSync` on GET /api/v1/sources) was reached and the run stopped there \u2014 an ORDINARY ending, not a failure: `state` stays `completed`, `errorCode` is null, nothing broke. It is the ending `capture.stoppedBy` spells `credits`: both spellings were published first and callers branch on each, so neither is renamed. `exhausted`, `capture_empty` and `provider_limit` mean exactly what they mean on `capture.stoppedBy`. `untracked` means the source was untracked while the run was in flight and the sweep was abandoned \u2014 an ending the capture vocabulary has no word for, so `capture.stoppedBy` is `null` beside it. `null` means there is no ending to name: no run has finished yet, the run FAILED (read `state` and `errorCode`), or this is a KEYWORD sweep, which records how it ended on keyword_searches.last_run_stopped_by and GET /api/v1/sources reports as `lastRun.stoppedBy`. \u26a0 CHANGED IN THIS RELEASE: this field used to be `null` for every ordinary run \u2014 beside `capture.stoppedBy: \"exhausted\"`, and beside a post the provider had cut off at a third of its reactions \u2014 so a caller reading only this field saw no ending at all. \u26a0\ufe0f THOSE TWO FIELDS ARE NOT THE SAME LIST: `lastRun.stoppedBy` answers \"how did this keyword sweep end\" (budget / credits / exhausted / error / ai_error / team_cap / lead_cap); this one answers how a person, company-page or tracked-post CAPTURE ended. Read from the same job row the rest of this response describes, so it is about the CURRENT run and not about the source's history. PER SYNC, EVERY SYNC: it bounds EACH sync of that one person, company page or post \u2014 the capture cap counts lead rows, while billing charges once per new person per source \u2014 not the first pull only and not the life of the source, and there is NO ESTIMATE, NO CONFIRMATION GATE AND NO TEAM CEILING behind it: nothing prices a sync before you set the cap, raising or lowering it never needs `confirmSpend`, and the team's `dailyCeiling` does not count a credit of it. \u26a0\ufe0f A KEYWORD SEARCH'S `creditCap` IS THE OTHER FIELD AND HAS ALL THREE: it is the daily bound on a RECURRING SWEEP, POST /api/v1/keyword/estimate prices it as `estimatedDailyMax`, `confirmSpend` gates a create or a raise with a `409 spend_confirmation_required`, and the team's `dailyCeiling` stops it \u2014 and `dailyCeiling` COUNTS KEYWORD SPEND ONLY, so no number of profile syncs can ever reach it."
                    },
                    "lastSyncedAt": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true
                    },
                    "nextSyncAt": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true,
                      "description": "When the next scheduled sync is due (daily cadence). null while a sync is currently running \u2014 there is no next run scheduled until the active one finishes; use `state`/`isFinal` to see it is in progress. null for a source that is not ACTIVE \u2014 untracked, or paused after repeated not-found errors \u2014 always: nothing will run it (untracking parks its schedule, and a stored value is never served for a source that is not active)."
                    },
                    "leadsReady": {
                      "type": "boolean",
                      "description": "True once leads are ready to read."
                    },
                    "error": {
                      "type": "string",
                      "nullable": true,
                      "description": "Human-readable failure reason when `state` is `failed`. Owned Cornersight text - it never contains the upstream data provider's raw response. Wording may change; branch on `errorCode`."
                    },
                    "lifecycleEvent": {
                      "type": "object",
                      "description": "Whether a sync.completed / sync.failed callback was attempted for this source, and what became of it. `enabled` is true only when syncEvents is on AND a webhook URL is set. `lastDelivery` is the most recent delivery recorded for this source, or null when none has ever been enqueued \u2014 so `enabled: true` with `lastDelivery: null` after a finished run means nothing was attempted, which is a different problem from an endpoint that rejected the payload. A delivery enqueued but not yet attempted reads `status: \"queued\"` with its `queuedAt`, and `stalled: true` once it has waited five minutes without a first attempt. An explicit lead push is reported here too when it is the newest delivery on the source, so `lastDelivery` can be set while `enabled` is false. Read-only; a source with lifecycle events off and no push reports `{ enabled: false, lastDelivery: null }`.",
                      "properties": {
                        "enabled": {
                          "type": "boolean",
                          "description": "Is this source configured to emit lifecycle events at all?"
                        },
                        "lastDelivery": {
                          "type": "object",
                          "nullable": true,
                          "description": "The newest delivery recorded for this source: an outbox row, or the newest explicit lead push when that is more recent. `event` (sync.completed | sync.failed | spend.cap_reached | spend.ceiling_reached | lead.detected \u2014 the outbox carries the first four, so a `lastDelivery` naming a spend event is not a stray: it is the most recent attempt for this source, whichever event it was; lead.detected means an explicit push), `syncId` (which run it describes \u2014 compare against `syncId` above to tell this run from the previous one; null for a push), `status` (queued = enqueued and not yet attempted | pending = attempted, waiting for its next retry | delivering | delivered | failed \u2014 and held, for a push whose every lead the webhook's icpOnly filter took), `attempts`, `maxAttempts` (for a push: its leads attempted so far, and its leads in all), `responseStatus` (the HTTP status your endpoint returned on the last attempt; null on a transport failure and for a push), `lastError`, `queuedAt`, `deliveredAt`, `nextAttemptAt` (null once the delivery is terminal), `stalled` (true when still queued, never attempted, five minutes or more after `queuedAt` \u2014 a delay on Cornersight's side, not your endpoint), and for a push `pushId` (for GET /api/v1/push/{pushId}) and `counts` (pending, delivering, delivered, failed, held)."
                        }
                      }
                    },
                    "errorCode": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "enum": [
                        "insufficient_enriching_credits",
                        "not_found",
                        "page_out_of_range",
                        "invalid_request",
                        "provider_rate_limited",
                        "provider_access_denied",
                        "provider_unavailable",
                        "internal_error",
                        "ai_error",
                        "search_term_rejected",
                        null
                      ],
                      "description": "Stable, machine-readable reason for the failure, or `null` when the sync has not failed. The values JobStatus.errorCode uses, plus two that only a KEYWORD sweep produces and that are the CUSTOMER'S to fix: `ai_error` \u2014 the search's AI filter failed on the team's own AI setup (a model the provider does not recognise, a revoked key, an exhausted quota); `error` says which and what to change, in the same words as `lastRun.reason`, and `lastRun.aiErrorCode` carries the fine-grained code. `search_term_rejected` \u2014 the data provider refused one of the search's terms (HTTP 400); `error` names the term. The same term is refused on every run, so edit the search's keywords \u2014 retrying will not help. Before this release both were reported as `internal_error` (\"retrying may succeed\"), which told the customer the problem was Cornersight's."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The path segment is not a source id, or the body is invalid."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TrackedProfileDeleteForbidden"
          },
          "404": {
            "description": "No keyword search with that id for this team."
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/v1/sources": {
      "get": {
        "tags": [
          "Leads"
        ],
        "summary": "List tracked sources",
        "operationId": "listSources",
        "description": "The LinkedIn people, company pages, individual posts, and keyword searches this team tracks as lead sources. Create a person or company source with the enrich endpoints (saveTrackedProfile:true) and delete one with DELETE /api/v1/profile|/company/{username}. Tracked POSTS are created with POST /api/v1/post/track (or the dashboard); they are listed here, filterable with ?type=post, and their engagers are returned by GET /api/v1/leads like any other source's. Untrack one with DELETE /api/v1/post/{urn}. Their sync status, webhook config and ICP config are reached by SOURCE ID at /api/v1/sources/{id}/sync, /webhook and /icp \u2014 the `id` of each entry below. The username-keyed /sync, /webhook and /icp routes cover person and company sources only, so a post's URN 404s there. A keyword search reaches all three by source id twice over: at /api/v1/keyword/{id}/webhook, /icp and /sync, and at the kind-agnostic /api/v1/sources/{id}/... routes, which take the same id. KEYWORD SEARCHES are created with POST /api/v1/keyword/track (or the dashboard); they are listed here, filterable with ?type=keyword, and their engagers are returned by GET /api/v1/leads the same way. Untrack one with DELETE /api/v1/keyword/{id}. Their /sync, /webhook and /icp are reachable by source id, on their own routes and on the kind-agnostic /api/v1/sources/{id}/... ones alike. Their AI key is NOT set through the API \u2014 it is stored once per team per provider in the dashboard's AI filtering panel, on Keyword Engagement (the same panel that sets the provider and prompt, both when creating a search and when editing one), and the create route rejects a key outright rather than ignoring it. THERE IS NO SEPARATE INTEGRATIONS PAGE \u2014 this doc used to point at one, and no such page exists. Each source's `id` is the `profileId` GET /api/v1/leads filters by. By DEFAULT this lists only ACTIVE sources; `?includeInactive=true` adds the untracked ones, each carrying `status: \"inactive\"`. That flag is how a stopped keyword search is found again \u2014 its leads are kept, and without it neither its id nor its lead count is reachable.",
        "parameters": [
          {
            "name": "type",
            "in": "query",
            "required": false,
            "description": "Filter to one kind of source. An unrecognised value is rejected with 400. `post` matches tracked LinkedIn posts, which are created in the Cornersight dashboard rather than through this API.",
            "schema": {
              "type": "string",
              "enum": [
                "person",
                "company",
                "post",
                "keyword"
              ]
            }
          },
          {
            "name": "includeInactive",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean",
              "default": false
            },
            "description": "Also list UNTRACKED sources. Untracking is a soft delete on every kind \u2014 the row is deactivated rather than removed, because leads reference it \u2014 so an untracked source is a real row this list simply stops naming. Each one comes back with `status: \"inactive\"`, so the two can never be confused; combine with `?type=keyword` to list every keyword search the team has ever had.\n\nWHY YOU WOULD WANT IT. Untracking a keyword search KEEPS the leads it captured, and without this the search's id becomes undiscoverable \u2014 so a per-search breakdown silently omits every search the user has stopped. This is the public equivalent of what the dashboard's own keyword leads view does; `GET /api/v1/leads?sourceKind=keyword` is the same answer in aggregate and needs no ids at all.\n\nTHIS LISTS SOURCES; IT DOES NOT SERVE LEADS \u2014 AND THE LEADS HAVE THEIR OWN FLAG. Listing an untracked person, company or post source tells you it existed and when. Its kept leads are read back by passing the SAME PARAMETER to the endpoints that serve them: `GET /api/v1/leads?includeInactive=true` and `GET /api/v1/engagers?includeInactive=true`, which also make the id you got here resolve there instead of answering 404. WITHOUT that flag those leads stay out of both endpoints, which is the default and matches the dashboard \u2014 the dashboard has no such opt-in at all, so nothing this flag does changes what a customer sees on screen. Keyword searches need no flag anywhere: their leads stay readable after untracking, on every surface. READING IS NOT ACTING: an untracked source still cannot be pushed, and its `/webhook` and `/icp` routes still 404, whatever flag you pass. An unrecognised value (`1`, `yes`) is a 400 rather than a silent false."
          }
        ],
        "responses": {
          "200": {
            "description": "The team's tracked sources, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "sources": {
                      "description": "Every source this team tracks, newest first. The fields below are the source row; `lastRun` is populated only for keyword searches.",
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string",
                            "format": "uuid",
                            "description": "The tracked source's id. This is the value GET /api/v1/leads accepts as `profileId` \u2014 use it to scope a leads query to this source."
                          },
                          "username": {
                            "type": "string",
                            "description": "LinkedIn handle. This is what DELETE /api/v1/profile/{username} or /company/{username} takes to untrack the source."
                          },
                          "url": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "description": "LinkedIn profile/company URL."
                          },
                          "type": {
                            "type": "string",
                            "enum": [
                              "person",
                              "company",
                              "post"
                            ],
                            "description": "What kind of source this is. `person` and `company` are tracked through the enrich endpoints (saveTrackedProfile:true); `post` is a single LinkedIn post tracked from the Cornersight dashboard \u2014 this API can list and filter posts but cannot create them."
                          },
                          "displayName": {
                            "type": [
                              "string",
                              "null"
                            ]
                          },
                          "avatarUrl": {
                            "type": [
                              "string",
                              "null"
                            ]
                          },
                          "status": {
                            "type": [
                              "string",
                              "null"
                            ]
                          },
                          "leadsReady": {
                            "type": "boolean",
                            "description": "Whether this source's leads are currently readable \u2014 NOT whether any leads exist. On person, company and post sources it is false only while a sync or its enrichment is in flight, so it does track sync state there. On a KEYWORD search it is true from creation and never indicates the first sweep has happened \u2014 the sweep begins within about a minute of creation, but during that window and always, use lastRun to ask whether it has run. On no source kind does true mean leads were found: a source that swept and captured nothing is also true. An absent-profileId /api/v1/leads query only reads sources where this is true."
                          },
                          "isTrialProfile": {
                            "type": "boolean",
                            "description": "Trial sources cannot be untracked (DELETE returns 403)."
                          },
                          "lastSyncedAt": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "format": "date-time"
                          },
                          "nextSyncAt": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "format": "date-time",
                            "description": "When this source's next sync is due (daily cadence). null while a sync is currently running \u2014 the same value GET /{type}/{username}/sync reports, so an overview needs no per-source call. Null too for a keyword search whose SCHEDULE has ended (see `schedule.stoppedAt`), and for every source that is not active \u2014 UNTRACKED (`status: \"inactive\"`, listed with ?includeInactive=true) or `paused`: in each case there is no next run to name."
                          },
                          "createdAt": {
                            "type": "string",
                            "format": "date-time"
                          },
                          "icp": {
                            "type": "object",
                            "description": "The source's ICP criteria (the filter behind isIcp / icpOnly). Read-only here; set via PUT /api/v1/{type}/{username}/icp. rules is [] when there is no ICP filter.",
                            "required": [
                              "matchMode",
                              "rules"
                            ],
                            "properties": {
                              "matchMode": {
                                "type": "string",
                                "enum": [
                                  "all",
                                  "any"
                                ]
                              },
                              "rules": {
                                "type": "array",
                                "items": {
                                  "type": "object",
                                  "required": [
                                    "column",
                                    "operator",
                                    "value"
                                  ],
                                  "properties": {
                                    "column": {
                                      "type": "string"
                                    },
                                    "operator": {
                                      "type": "string"
                                    },
                                    "value": {
                                      "type": "string"
                                    }
                                  }
                                }
                              }
                            }
                          },
                          "creditCapPerSync": {
                            "type": "integer",
                            "format": "int32",
                            "nullable": true,
                            "description": "The most credits ONE SYNC of this source may spend, where the capture cap counts lead rows while billing charges once per new person per source (tracked_profiles.credit_cap, migration 147). `null` means NO LIMIT, which is what every source without one holds \u2014 there is deliberately no default. Set it with `creditCapPerSync` on POST /api/v1/enrich/{profile,company} (with saveTrackedProfile: true) or POST /api/v1/post/track, and CHANGE it without re-syncing with PATCH /api/v1/{profile,company}/{username}. \u26a0\ufe0f RENAMED from `creditCap` in 4.0.0, because `config.creditCap` on this SAME response is a keyword search's per-RUN cap \u2014 one response object, two different numbers, one name, told apart only by knowing the source's kind. The keyword field was published first, so this one moved; there is no alias. A sync that ENDED on this limit reports `stoppedBy: \"credit_cap\"` on GET /api/v1/sources/{id}/sync \u2014 that is a fact about one RUN, so it lives on the run rather than here, and reading it for every row of this list would be one query per source. \u26a0\ufe0f ABSENT ON A KEYWORD SOURCE: a keyword search's per-run limit is `config.creditCap` (keyword_searches.credit_cap) and this column is ignored for it, so there is exactly one place to read a given source's limit. PER SYNC, EVERY SYNC: it bounds EACH sync of that one person, company page or post \u2014 the capture cap counts lead rows, while billing charges once per new person per source \u2014 not the first pull only and not the life of the source, and there is NO ESTIMATE, NO CONFIRMATION GATE AND NO TEAM CEILING behind it: nothing prices a sync before you set the cap, raising or lowering it never needs `confirmSpend`, and the team's `dailyCeiling` does not count a credit of it. \u26a0\ufe0f A KEYWORD SEARCH'S `creditCap` IS THE OTHER FIELD AND HAS ALL THREE: it is the daily bound on a RECURRING SWEEP, POST /api/v1/keyword/estimate prices it as `estimatedDailyMax`, `confirmSpend` gates a create or a raise with a `409 spend_confirmation_required`, and the team's `dailyCeiling` stops it \u2014 and `dailyCeiling` COUNTS KEYWORD SPEND ONLY, so no number of profile syncs can ever reach it."
                          },
                          "captureReplies": {
                            "type": "boolean",
                            "description": "Whether this source captures comment-reply authors as leads. Defaults to true; false skips replies before lead writes and credits."
                          },
                          "enrichLeads": {
                            "type": "boolean",
                            "description": "Whether this source's leads are enriched. Defaults to true. false = RAW MODE: engagers are captured and charged exactly like enriched ones (one credit per new person per source, repeats free) but never enriched, and they are read with GET /api/v1/leads/raw \u2014 never GET /api/v1/leads, /engagers, exports, webhooks or integrations. Set with `enrichLeads` on POST /api/v1/enrich/{profile,company} (with saveTrackedProfile: true), POST /api/v1/post/track, POST /api/v1/keyword/track, and changed with PATCH /api/v1/{profile,company}/{username} or PATCH /api/v1/keyword/{id}. A change applies to leads captured after it."
                          },
                          "lastRun": {
                            "type": "object",
                            "nullable": true,
                            "description": "The outcome of this source's last sweep. `null` for person, company and post sources, which have no sweep of their own. For a keyword search this is always an object; `stoppedBy`, `reason`, `at`, `postsScanned` and `postsKept` are null until the first run finishes, so a keyword search that has never run is distinguishable from a source that can never have one. THESE OPTIONAL FIELDS \u2014 `postsHarvested`, `engagersSeen`, `engagersDropped`, `engagersDuplicate`, `leadsWritten`, `providerRowsDropped`, `providerPageLimitReached`, `postsAvailable`, `caughtUp`, `creditsSpent` and `config` \u2014 ARE OMITTED RATHER THAN NULL when the last run predates the column that records them, so test for the KEY's presence (`'engagersSeen' in lastRun`) rather than for a value: an absent field means that run did not measure it, while a `0` or `false` is a measurement. This \u2014 not leadsReady \u2014 is the signal that a keyword search has run: `at` null means it has never swept, and `postsScanned`/`postsKept` distinguish 'ran and found nothing' from 'never looked'.",
                            "properties": {
                              "reason": {
                                "type": "string",
                                "nullable": true,
                                "description": "WHY the run ended, in the words of whatever stopped it \u2014 the sentence behind the `stoppedBy` category. For `ai_error` this is a sentence CORNERSIGHT OWNS, naming which of four things went wrong and what to do about it, with `aiErrorCode` beside it as the machine-readable half. It USED TO BE the AI provider's entire response envelope, quoted wholesale (`openai returned 404: {\"error\": {\"message\": \u2026, \"type\": \"invalid_request_error\", \"param\": null, \"code\": \"model_not_found\"}}`) \u2014 multi-line JSON in a one-line string field, whose shape was the vendor's to change without notice and whose only stable token was buried in per-vendor prose. The provider's raw response is now kept in Cornersight's logs and served by nothing. Every OTHER stop is unchanged: a small sweep still explains itself here (\"no new posts: 12 matching posts were already captured by earlier runs\"). A run whose boolean expression discarded EVERY post it harvested says so here, and whether those posts were marked seen and listed at kept-posts?include=swept. When every term's walk reached the end of what LinkedIn's search returned, it says that too \u2014 `the search reached the end of what LinkedIn's search returned for its terms and found no exact matches for this search's expression` \u2014 and advises broadening the terms in a new search; a walk stopped by the 2,000-post scan ceiling, the 20-page bound or a page of posts earlier runs had seen makes no such claim, because more results may lie beyond it. `null` before the first run, and `null` when whatever stopped the run recorded no text \u2014 a normal `credits` or `exhausted` stop usually has none."
                              },
                              "aiErrorCode": {
                                "type": "string",
                                "enum": [
                                  "model_not_found",
                                  "invalid_key",
                                  "rate_limited",
                                  "out_of_credit",
                                  "provider_error"
                                ],
                                "description": "WHICH KIND of AI-filter failure this was, as a stable token to branch on \u2014 the machine-readable half of `reason` for an `ai_error`. PRESENT ONLY when the run failed at the AI filter; absent (not null) otherwise, including for every run that stopped for any other reason. Split by WHO MUST ACT: `model_not_found` \u2014 the search names a model the provider will not serve, so clear `config.aiModel` to use the default or set one the account can reach; `invalid_key` \u2014 the stored key was refused, or none is saved for that provider; `rate_limited` \u2014 the provider is throttling or that key's quota is spent, and the next daily run will try again; `out_of_credit` \u2014 the provider account behind the key has no credit left (an HTTP 402, Anthropic's \"credit balance is too low\", OpenAI's `insufficient_quota`, xAI's credits-exhausted response), so the owner must add credit or check billing with the provider; the key itself is fine and must not be rotated; `provider_error` \u2014 THE PROVIDER'S OWN FAILURE (its 5xx, its outage), which is NOT a credential problem and must never be reported as one. The first four are the search owner's to fix; `provider_error` asks only for a retry. Absent on runs that predate this field."
                              },
                              "stoppedBy": {
                                "type": "string",
                                "nullable": true,
                                "enum": [
                                  "budget",
                                  "credits",
                                  "exhausted",
                                  "error",
                                  "ai_error",
                                  "team_cap",
                                  "lead_cap",
                                  "capture_empty",
                                  "post_limit",
                                  null
                                ],
                                "description": "Why the last run ended. `budget` - NO LONGER PRODUCED: it named a per-run post limit that was retired on 30 September 2026, and appears only on a search whose last run predates that. `credits` - it spent creditCap credits, which is the usual stop for a broad search because a credit is charged per new person for this search; repeat engagements are free. On a `posts_only` search it means `creditCap` was BELOW `postsPerSync` and was reached. `post_limit` - a `posts_only` search bought the `postsPerSync` new posts it may buy in a day. An ORDINARY ending, and the setting to raise for more posts is `postsPerSync`, not `creditCap`: in the usual case `postsPerSync` is the lower of the two, so the credit cap was never reached. `exhausted` - the provider walk ended without a capture cap. `providerPageLimitReached: true` means a safety bound on the walk \u2014 20 provider pages per term, or the run's 2,000-post scan ceiling \u2014 ended it before later pages were proven empty; false only says that bound did not fire, because an all-seen RELEVANCE page can also stop before later unseen posts. `error` - the sweep itself failed. `ai_error` - the team's own AI credential failed, which is the only value that asks the team to do something. `team_cap` - the TEAM's daily ceiling was reached, not this search's own cap: the run stopped AT the ceiling as a partial run rather than a refusal, the day's remaining searches are skipped with the same marker, and everything runs again tomorrow \u2014 so it is the team's settings, not this search's creditCap, that a reader should look at. `lead_cap` - this SOURCE's own lead cap is spent, which is neither a credit cap nor a ceiling anyone can raise in Settings: a keyword search on a trial holds at most 250 leads, and once it has them the daily sweep is skipped before any search, harvest or charge, so every counter reads zero. It is an ORDINARY ending and not a failure - the search is doing exactly what the plan allows. `leadsWritten` tells a skip (0) from a run that reached the cap part-way through (non-zero), the same way it does for `team_cap`, and it stays this way until the cap rises or leads are removed. `capture_empty` - the sweep HARVESTED REAL POSTS and every engager fetch answered and returned NOBODY, which is a statement about the CAPTURE rather than about the posts: between 12 and 14 September 2026 the upstream engager endpoints answered 200 with no rows for three days, every run of every search reported itself a clean `exhausted`, and it reached us as a customer complaint rather than as an alert. It is a FAILURE ending like `error` \u2014 which is the value it was recorded under before it had its own name \u2014 so the run's sync job is failed and `sync.failed` fires. READ IT WITH THE COUNTERS BESIDE IT: `postsHarvested` is the N in the reason's \"harvested N posts, captured nobody\" and `engagersSeen` is the measured 0, so the state is machine-readable without parsing the sentence. \u26a0 A SEARCH THAT FOUND NOTHING NEVER REPORTS IT: a run that harvested NO posts has no harvest to have captured nobody from, and stays a clean `exhausted` with every counter at 0. `null` before the first run."
                              },
                              "at": {
                                "type": "string",
                                "format": "date-time",
                                "nullable": true,
                                "description": "When the last run finished. `null` before the first run."
                              },
                              "postsScanned": {
                                "type": "integer",
                                "nullable": true,
                                "description": "Posts the last run considered that it had NOT SEEN BEFORE. Not a count of posts matching the term: a search remembers everything it has already swept, so its FIRST run reads everything it can reach and later runs report only what the window has produced since \u2014 a healthy established search reporting 1 or 2 here is normal, not a failed query. When a run is small because earlier runs already took the matches, `reason` says how many were already captured; a run that found nothing AND has nothing already swept is the case where the term really did match nothing. OUR REQUEST DOES NOT VARY WITH THE TERM \u2014 it reaches the provider verbatim, which the search body proves \u2014 BUT WORD-COUNT ADVICE IS RETRACTED AGAIN 2026-09-24: seven brand-new single-word and phrase searches all reached the 25-post limit they ran under then; a fresh single word scanned 179 under a 250-post limit. Both were first runs, so no prior seen-set explains the measurement. The older 2026-09-09 `sales` versus `sales team` historical run remains unproven: it predates `providerRowsDropped`, and it did not reproduce. Since 30 September 2026 no setting limits how many posts a run considers: a run reads up to 2,000 posts, and the credit cap can still stop capture sooner. On a current run, read `providerRowsDropped`: positive means uncapturable provider rows were skipped, 0 means no provider row was skipped, and absent means unmeasured. A nonterminal all-dropped page is skipped and the walk continues; `providerPageLimitReached` says whether the 20-page safety bound prevented proof of exhaustion. The worker never converts a `ugcPost` or `share` identifier into an `activity` URN and never widens a term. The dashboard keyword row shows an approximate first-page provider match estimate summed across terms (shared posts may double-count and totals can drift), plus a caught-up message when every term ended on posts swept by earlier runs. The same measured diagnostics are returned on this response as `postsAvailable` (approximate first-page totals summed across terms, so shared posts may count twice and totals can drift) and `caughtUp` (true when every term ended on posts swept earlier); both are omitted when unmeasured. A POST A BOOLEAN `expression` DISCARDED IS COUNTED HERE TOO \u2014 the run read it in order to discard it \u2014 and is NOT counted in `postsKept`; `lastRun.discardedByExpression` is how many of this number those were, and kept-posts?include=swept names them one by one. `null` before the first run. WHAT IT COUNTS, EXACTLY: every post the run READ \u2014 the posts that reached its filters, INCLUDING the ones a boolean expression discarded, and after the 2,000-post scan ceiling has trimmed the survivors. So on a run with no AI filter postsScanned = postsKept + discardedByExpression exactly (`discardedByExpression` absent counts as 0); with an AI filter, postsScanned = postsKept + discardedByExpression + the posts it rejected, and on an `ai_error` run the remainder also holds posts it never decided. Runs recorded before 2026-09-21 counted only the posts that SURVIVED the expression, which is how an old record can show postsScanned 1 beside discardedByExpression 9."
                              },
                              "postsKept": {
                                "type": "integer",
                                "nullable": true,
                                "description": "Posts that survived the AI filter, or all scanned posts when no filter is set. `null` before the first run. `postsScanned` -> `postsKept` is the FILTER's before/after; `postsHarvested` is a separate question \u2014 how far the run reached. ON A SEARCH WITH A BOOLEAN `expression` THE DROP IS TWO STEPS, NOT ONE: the expression discards first (`discardedByExpression` counts those, and they are in `postsScanned`) and the AI filter judges what is left, so `postsScanned` minus `postsKept` is the two together and only the swept list says which post went to which."
                              },
                              "postsHarvested": {
                                "type": "integer",
                                "description": "Posts the last run actually HARVESTED \u2014 reached and captured \u2014 before it stopped. The capture loop breaks at `creditCap` (or, on a `posts_only` search, `postsPerSync`), so this can be far below `postsKept`: a run that kept 25 posts but hit its credit cap after the first harvests 1. Counts a post whose engagers were ALL already captured on a prior run (0 new leads) because the sweep still fetched and deduped them \u2014 it measures REACH, not lead yield, and a harvested-to-zero post is not the same as one the cap never reached. OMITTED (the key is absent, never 0) when the last run predates this field \u2014 a 0 would falsely claim it harvested nothing."
                              },
                              "engagersSeen": {
                                "type": "integer",
                                "description": "Engagers the last run's captures received, summed across posts \u2014 the denominator for the three buckets below. HOW TO READ THE FOUR TOGETHER: engagersSeen 0 means the posts were quiet; engagersDuplicate > 0 means those people were already captured (free, not a loss); engagersDropped > 0 means real people were seen and discarded. \u26a0 KNOWN GAP \u2014 engagersSeen 0 IS NOT PROOF THE POSTS WERE QUIET. It counts only what the provider pagers RETURNED, and those pagers discard rows of their own beforehand (they tally them in local counters they log and throw away), so a post whose engagers were all dropped upstream reports seen 0 and is indistinguishable here from a post nobody engaged with. Read seen 0 as \"the capture received nothing\", never as \"nothing was there\". Closing that needs a pager-level drop count, which is not yet recorded. OMITTED (never 0) when the run predates this field. OMITTED (like engagersDropped and engagersDuplicate) for a run that fetched no engagers at all — a keyword search with captureEngagers false, or a posts-only keyword search (mode posts_only), which captures no people — because such a run did not measure them; its post authors are reported as postAuthorsCaptured."
                              },
                              "engagersDropped": {
                                "type": "integer",
                                "description": "Engagers the last run saw but could not turn into a lead. NARROWER SINCE 2026-09-08: an engager with no public handle is no longer dropped \u2014 the lead is keyed on their member URN \u2014 so what remains here is an engager with NO identity at all (neither handle nor URN) and an ORGANISATION page, which is not a person and can never be a lead. A non-zero value is therefore mostly \"these were not leads\" rather than \"we lost people\"; the loss it used to measure is the reason the URN fallback exists. A measured 0 is reported as 0; the field is OMITTED entirely when the run predates it, so absent and zero never read the same."
                              },
                              "engagersDuplicate": {
                                "type": "integer",
                                "description": "Engagers already accounted for without a new charge: both exact stored-row collisions and new engagement rows by a person this source already charged. `repeatEngagements` is the latter subset. OMITTED when the run predates this field."
                              },
                              "repeatEngagements": {
                                "type": "integer",
                                "description": "New lead rows by people already charged for this source in an earlier engagement. These rows are free, and are also included in `engagersDuplicate`. OMITTED for runs before this counter was recorded; a measured zero is 0."
                              },
                              "leadsWritten": {
                                "type": "integer",
                                "description": "Lead rows the last run actually inserted, including free repeat engagements; this can exceed `creditsSpent`. OMITTED when the run predates this field. NOTE the four numbers are buckets, not a closed identity: engagersSeen can exceed leadsWritten + engagersDropped + engagersDuplicate, and the remainder is the per-post allowance clamp leaving a post's surplus engagers for the next run."
                              },
                              "postsFilteredOut": {
                                "type": "integer",
                                "description": "How many harvested posts this run discarded because they failed the AND/NOT terms of the search's `config.expression`. OMITTED when the search has no expression, and also on runs predating the field; PRESENT AND 0 when an expression bound the run and discarded nothing \u2014 those are different answers and must not be collapsed. THIS IS THE FIELD THAT TELLS A NARROW EXPRESSION FROM A BROKEN SEARCH: `postsHarvested: 0` with `postsFilteredOut: 47` is an expression to rewrite; `postsHarvested: 0` with the key absent or 0 is a search that found nothing. Such a run ends `stoppedBy: \"exhausted\"` \u2014 nothing broke \u2014 with `reason` saying so in a sentence. Discarded posts ARE counted in `postsScanned` \u2014 the run READ them, which is how it discarded them \u2014 and are NOT counted in `postsKept`. Each one is also LISTED: GET /api/v1/sources/{id}/kept-posts?include=swept returns a row per discarded post with `outcome: \"discarded\"` and a `reason` naming the part of the expression it failed (`missing phrase \"sales ops\"`, `contains recruiter`). Until 2026-09-21 they were counted in neither and listed nowhere, so a run that harvested a page of posts and kept one reported `postsScanned: 1` and the only record of the rest was this number. They ARE marked seen, so the next run does not pay to harvest them again, and they cost no credits."
                              },
                              "discardedByExpression": {
                                "type": "integer",
                                "description": "THE SAME NUMBER AS `postsFilteredOut`, under the name that says WHAT discarded the posts. The run summary reads \u201csearched N, discarded M, kept K\u201d off this one: `postsScanned` is N, this is M and `postsKept` is K. `postsFilteredOut` is the older name and stays because callers already branch on it, but \u201cfiltered out\u201d is ambiguous in a sweep where an AI filter also filters. ONE COLUMN READ, SERVED TWICE: the two are always both present or both absent and can never differ. Present if and only if an AND/NOT expression bound the run \u2014 0 included, absent for a search that has no expression and for runs predating the field. On a run with no AI filter the three reconcile exactly \u2014 N = K + M \u2014 and GET /api/v1/sources/{id}/kept-posts?include=swept returns M `discarded` rows, one per post, even when K is 0. A run that stopped before applying its expression (a cap skip, an untrack, an unreadable plan) omits this key rather than repeat the previous run's count."
                              },
                              "providerRowsDropped": {
                                "type": "integer",
                                "description": "Provider RESULT ROWS the last run skipped during post normalisation because they did not carry a capturable `urn:li:activity:*` id. This counts rows across terms and pages, not necessarily unique posts. Cornersight does not relabel `ugcPost` or `share` ids because those ids are not the corresponding activity id and doing so can attribute another post's engagers to this source. The collector CONTINUES past a NONTERMINAL all-dropped page, bounded by 20 pages, instead of treating it as provider exhaustion; a page the provider marks `last` still ends the walk without buying nonexistent pages. PRESENT AND 0 when measured and nothing was dropped; OMITTED when the run predates migration 159 or made no measured provider search."
                              },
                              "providerPageLimitReached": {
                                "type": "boolean",
                                "description": "Whether any keyword term in the last run reached the provider walker's 20-page safety bound before proving later pages empty. It is also true when the run's merged posts reached its 2,000-post scan ceiling with more left to read. Read this with `stoppedBy`: true plus `exhausted` means later pages may still contain capturable posts; true plus `credits` or `post_limit` means the capture's real cap still ended the merged run and remains authoritative. PRESENT as true or false after a measured provider walk; OMITTED when the run predates migration 159 or made no provider search. False only says the 20-page bound did not fire; an all-seen page under RELEVANCE ordering can still stop before later unseen posts."
                              },
                              "postsAvailable": {
                                "type": "integer",
                                "description": "The provider's approximate first-page total for the search terms when this run measured it. Terms are summed, so shared posts can be double-counted and provider totals can drift. OMITTED when migration 165 is unavailable or no provider total was returned. This is an estimate, not a promise of unique or capturable posts."
                              },
                              "caughtUp": {
                                "type": "boolean",
                                "description": "Whether every searched term ended on posts already swept by earlier runs. PRESENT as true or false when measured; omitted when the run predates migration 165 or no provider search was made. A measured false is not interchangeable with a missing value."
                              },
                              "postAuthorsCaptured": {
                                "type": "integer",
                                "description": "Author lead rows this run wrote — post authors who were new people (charged, included in creditsSpent) or free repeats (included in repeatEngagements). PRESENT only when the run captured post authors, and then present even at 0; omitted for an engagers-only run and for runs before migration 176. Author rows are also counted in leadsWritten."
                              },
                              "companyAuthorsSkipped": {
                                "type": "integer",
                                "description": "Kept posts in this run whose author was a COMPANY PAGE, so no author was captured and nothing was charged. PRESENT only when the run captured post authors, and then present even at 0."
                              },
                              "creditsSpent": {
                                "type": "integer",
                                "description": "What the last run COST, in credits: newly chargeable people for this source, not all engagement rows. Repeat engagements can make `leadsWritten` larger without increasing this count. Before the distinct spend counter existed, historical runs use their lead-row count as a legacy estimate. This is capture-side committed spend; the enriching ledger may post charges later, so GET /api/v1/credits/usage can lag it."
                              },
                              "config": {
                                "type": "object",
                                "description": "THE SETTINGS THIS RUN ACTUALLY USED, snapshotted by the run itself \u2014 as against the source's own `config`, which is what the search is set to NOW and what its NEXT run will use. The two differ whenever the search was edited after this run started: a run reads its configuration once, when the sweep begins, and never re-reads it. That is what makes a run's counts interpretable. A run reporting `leadsWritten: 21` beside a stored `creditCap` of 5 is a cap overrun IF AND ONLY IF this object also says 5; if it says 100, the run finished inside the cap it was given and the smaller cap arrived afterwards. OMITTED (the key is absent, never null) when the last run predates this field \u2014 a run's settings are not knowable after the fact, and filling them in from the current columns would manufacture exactly the false certainty this exists to remove.",
                                "additionalProperties": false,
                                "properties": {
                                  "creditCap": {
                                    "type": "integer",
                                    "nullable": true,
                                    "description": "The credit cap that run was bound by \u2014 the number to read `creditsSpent` against; lead rows can exceed credits when repeat engagements are free."
                                  },
                                  "captureMode": {
                                    "type": "string",
                                    "nullable": true,
                                    "enum": [
                                      "depth",
                                      "breadth",
                                      null
                                    ],
                                    "description": "The capture mode that run used, resolved: a search that has never chosen one reports `depth`, which is what the run loop does with it."
                                  },
                                  "maxEngagementsPerPost": {
                                    "type": "integer",
                                    "nullable": true,
                                    "description": "The per-post ceiling that run applied, or `null` for no explicit ceiling \u2014 null is a value here, not a missing measurement."
                                  }
                                }
                              }
                            }
                          },
                          "creditsSpentToday": {
                            "type": "integer",
                            "description": "Credits this KEYWORD SOURCE has spent so far TODAY, over the UTC calendar day \u2014 the same day GET /api/v1/credits/usage dates `byDate` and `bySourceId` by. Present ONLY on keyword sources; the other three kinds have no sweep of their own. DISTINCT FROM `lastRun.creditsSpent`, which is ONE run: the two differ whenever a search ran more than once today, which is exactly the case a per-run number cannot explain. Summed across a team's sources this is `spentToday` on GET /api/v1/credits \u2014 the number the team's `dailyCeiling` is enforced against \u2014 because one grouped read serves both. OMITTED, never `0`, when the spend ledger could not be read; a `0` is a measurement."
                          },
                          "config": {
                            "type": "object",
                            "description": "WHAT THIS KEYWORD SEARCH IS SET TO: the numbers that bound each of its runs, and the AI filter that decides which of the posts they find are kept. Present ONLY on keyword sources \u2014 the other three kinds have no sweep of their own and no key at all, not a null one. Always an object on a keyword source, with null members if its configuration row could not be read, so \"not a keyword search\" and \"a keyword search we could not read\" never collapse into each other. These were write-only before this: settable at creation on all four surfaces (dashboard, API, CLI, MCP) and editable on two, and returned by no read operation \u2014 so a caller could not check the cap it had set, could not check WHICH MODEL its filter runs on, and an agent asked \"what is my budget?\" had nowhere to look. THIS IS THE NEXT RUN'S CONFIGURATION. What the LAST run used is `lastRun.config`, and the two disagree whenever the search was edited after that run began.",
                            "additionalProperties": false,
                            "properties": {
                              "creditCap": {
                                "type": "integer",
                                "nullable": true,
                                "description": "Enriching credits one run may spend before it stops (`stoppedBy: \"credits\"`). PER RUN, and the real bound on spend \u2014 a credit is charged per new person for this search; repeat engagements are free. 100 by default."
                              },
                              "captureMode": {
                                "type": "string",
                                "nullable": true,
                                "enum": [
                                  "depth",
                                  "breadth",
                                  null
                                ],
                                "description": "How the credit cap is spent across a run's posts. `depth` (the default, and what a search that never chose reports) lets each post spend the whole remaining cap; `breadth` shares it across posts and redistributes what they cannot use."
                              },
                              "maxEngagementsPerPost": {
                                "type": "integer",
                                "nullable": true,
                                "description": "Breadth only: the most engagements one post may contribute to a run. `null` means no explicit ceiling \u2014 fair-share the whole cap \u2014 which is a setting, not an unknown."
                              },
                              "captureEngagers": {
                                "type": "boolean",
                                "nullable": true,
                                "description": "Whether each run captures the people who liked or commented on its kept posts (migration 176). Default true. Null on a `posts_only` search, which captures no people, and when the search has no settings row."
                              },
                              "capturePostAuthors": {
                                "type": "boolean",
                                "nullable": true,
                                "description": "Whether each run captures the person who WROTE each kept post, as an `Author` lead — one credit per new person like any engager; company-page authors are skipped and not charged (migration 176). Default false. Null on a `posts_only` search and when the search has no settings row."
                              },
                              "aiProvider": {
                                "type": "string",
                                "nullable": true,
                                "enum": [
                                  "openai",
                                  "grok",
                                  "gemini",
                                  "claude",
                                  null
                                ],
                                "description": "Which stored credential filters this search's posts, or `null` for no AI filter \u2014 every post the terms find is then captured. NEVER THE KEY ITSELF: the key is held once per team per provider, encrypted, and is returned by no endpoint. Set with `aiProvider` on POST /api/v1/keyword/track."
                              },
                              "aiModel": {
                                "type": "string",
                                "nullable": true,
                                "description": "The model this search PINS, exactly as it was sent to POST /api/v1/keyword/track \u2014 or `null` for \"no model chosen\", which is a setting and not a missing value. NULL AND A MODEL ID ARE DIFFERENT REQUESTS and are deliberately not collapsed the way `captureMode` is: null follows the provider default as vendors retire models, an explicit id stays put and will 404 at the provider once that id is gone. Read `aiModelEffective` beside it for the id a run actually sends. `null` whenever `aiProvider` is null, since there is then no filter to configure."
                              },
                              "aiModelEffective": {
                                "type": "string",
                                "nullable": true,
                                "description": "THE MODEL THE NEXT RUN WILL ACTUALLY SEND: `aiModel` when this search pins one, otherwise the provider's default \u2014 openai `gpt-6-luna`, grok `grok-4.3`, gemini `gemini-3.5-flash-lite`, claude `claude-haiku-4-5-20251001`. Resolved by the SAME code path the filter itself calls, so it cannot disagree with what is sent, and it follows a default change rather than restating a constant that has moved \u2014 the claude default has a published retirement floor of October 2026. `null` only when `aiProvider` is null: no filter, so no model. This is the field to quote when a search's results look like the wrong model ran."
                              },
                              "estimatedDailyMax": {
                                "type": "integer",
                                "description": "THE MOST ONE DAY OF THIS SEARCH CAN COST, in enriching credits \u2014 the same field POST /api/v1/keyword/track returns when a search is created, recomputed here from the caps the NEXT run will be bound by, so a search created or edited anywhere can be read back. Produced by the one shared function every surface quoting a spend estimate reads; the formula is written out on POST /api/v1/keyword/track. OMITTED \u2014 not `0`, not `null` \u2014 when `creditCap` is missing or is not a positive whole number. Test for the KEY's presence, never for a value."
                              },
                              "daysToExhaustAtCap": {
                                "type": "integer",
                                "description": "WHOLE days the team's remaining enriching-credit balance funds at that daily maximum. `0` is a real answer and the warning one: the balance cannot fund one whole day at this cap, so the next sweep is already the one that gets cut short. OMITTED \u2014 never zero, never infinite \u2014 when there is no rate to divide by, or when the balance could not be read at all: that read is best effort and its failure removes this key rather than failing the listing. Test for the KEY's presence."
                              },
                              "expression": {
                                "type": "string",
                                "nullable": true,
                                "description": "The boolean expression this search was created from, in CANONICAL form \u2014 operators upper case, the implicit OR written out, and a multi-literal group bracketed when there is more than one group, e.g. `(hiring AND NOT recruiter) OR fundraising`. It is NOT an echo of what was sent: the canonical form is where the precedence is visible. It is also the one bracketed form the create accepts: sent back unchanged as `expression`, it compiles to the same search. `null` for every search created from a plain `keywords` list, and `null` too when the stored plan is a shape this build cannot read. `keywords` beside it is always the terms actually searched for, whichever way the search was made. Set with `expression` on POST /api/v1/keyword/track; it CANNOT be changed afterwards \u2014 PATCH /api/v1/keyword/{id} refuses it, because the seen-set is keyed to the SEARCH and a new expression would inherit the posts the old one rejected."
                              }
                            }
                          },
                          "schedule": {
                            "type": "object",
                            "description": "WHEN THIS KEYWORD SEARCH STOPS. Present ONLY on keyword sources \u2014 the other kinds have no sweep of their own \u2014 and always present on one. Every member at its default (`runOnce: false`, the rest null, `runsCompleted: 0`) is a search with no bounds, which is the unbounded daily cadence and is what every search created before this feature has; it is an ANSWER, not a missing value.",
                            "additionalProperties": false,
                            "properties": {
                              "runOnce": {
                                "type": "boolean",
                                "description": "Harvest once, then stop. `false` is the unbounded daily cadence."
                              },
                              "endAt": {
                                "type": "string",
                                "format": "date-time",
                                "nullable": true,
                                "description": "The UTC instant after which it stops scheduling, or null for no end date."
                              },
                              "maxRuns": {
                                "type": "integer",
                                "nullable": true,
                                "description": "The run budget, or null for none. See `maxRuns` on POST /api/v1/keyword/track for what counts as a run."
                              },
                              "runsCompleted": {
                                "type": "integer",
                                "description": "How many runs have COUNTED. See `maxRuns` for what counts; raise `maxRuns` above this number with PATCH /api/v1/keyword/{id} to restart a search that stopped at it."
                              },
                              "stoppedAt": {
                                "type": "string",
                                "format": "date-time",
                                "nullable": true,
                                "description": "When the scheduler stopped scheduling this search, or null while it still recurs. While this is set, `nextSyncAt` is null: there is no next run."
                              },
                              "stoppedReason": {
                                "type": "string",
                                "nullable": true,
                                "enum": [
                                  "run_once",
                                  "end_at",
                                  "max_runs",
                                  null
                                ],
                                "description": "Which control stopped it. DISTINCT FROM `lastRun.stoppedBy`, which says how the last RUN ended and is untouched by a schedule ending \u2014 a search can stop scheduling after a run that ended `exhausted` with a hundred leads, and overwriting that run's outcome would destroy the answer to \"how did my last run do\". Null WITH a `stoppedAt` set means the reason could not be stored."
                              }
                            }
                          },
                          "filters": {
                            "type": "object",
                            "description": "The provider-side TARGETING filters this keyword search was created with. Present ONLY on keyword sources, and only when at least one filter is set \u2014 a search using none has no `filters` key at all, so sources that do not use them are unchanged. Each value is the array of LinkedIn URNs (or, for authorKeyword, plain words) supplied at creation. These were previously write-only: settable on every surface and returned by none, so a caller could not verify what they sent. Read-only here; they are set at creation and corrected through the dashboard's edit route.",
                            "additionalProperties": false,
                            "properties": {
                              "authorIndustry": {
                                "type": "array",
                                "items": {
                                  "type": "string"
                                },
                                "description": "Industry ids (`urn:li:industry:96`): only posts whose author is in one of them were searched. Present only when non-empty. Reported in the canonical wrapped form whichever spelling was sent."
                              },
                              "authorCompany": {
                                "type": "array",
                                "items": {
                                  "type": "string"
                                },
                                "description": "LinkedIn company URNs: only posts whose author currently works at one of them. Present only when non-empty."
                              },
                              "authorKeyword": {
                                "type": "array",
                                "items": {
                                  "type": "string"
                                },
                                "description": "Plain words matched against the AUTHOR (headline, job title), not the post text \u2014 the only filter here that takes words rather than URNs. Present only when non-empty."
                              },
                              "fromPerson": {
                                "type": "array",
                                "items": {
                                  "type": "string"
                                },
                                "description": "Member ids (`urn:li:person:ACoAA\u2026`): only posts written by those people. Present only when non-empty. Reported in the canonical wrapped form whichever spelling was sent."
                              },
                              "fromCompany": {
                                "type": "array",
                                "items": {
                                  "type": "string"
                                },
                                "description": "LinkedIn company URNs: only posts published by those company pages. Present only when non-empty."
                              },
                              "mentionsPerson": {
                                "type": "array",
                                "items": {
                                  "type": "string"
                                },
                                "description": "Member ids (`urn:li:person:ACoAA\u2026`): only posts that @mention one of them. Present only when non-empty. Reported in the canonical wrapped form whichever spelling was sent."
                              },
                              "mentionsCompany": {
                                "type": "array",
                                "items": {
                                  "type": "string"
                                },
                                "description": "LinkedIn company URNs: only posts that @mention one of those pages. Present only when non-empty."
                              }
                            }
                          },
                          "firstSyncPosts": {
                            "type": "integer",
                            "minimum": 1,
                            "maximum": 50,
                            "nullable": true,
                            "description": "PERSON AND COMPANY SOURCES ONLY (absent on a tracked post or keyword search). What this source's FIRST sync was set to collect: the latest N posts, or `null` when nothing was chosen (the default, the latest 15). It describes the first sync only - every later sync checks the 4 newest posts - so on a source that has synced it is a record of the setting. Set with `firstSyncPosts` on POST /api/v1/enrich/{profile,company}. Omitted, rather than null, only when the value could not be read."
                          },
                          "firstSyncDays": {
                            "type": "integer",
                            "minimum": 1,
                            "maximum": 90,
                            "nullable": true,
                            "description": "PERSON AND COMPANY SOURCES ONLY (absent on a tracked post or keyword search). The days window this source's FIRST sync was set to collect (posts published in the last N days, at most 50 or at most `firstSyncPosts`), or `null` for no time limit. First sync only, like `firstSyncPosts`. Set with `firstSyncDays` on POST /api/v1/enrich/{profile,company}. Omitted, rather than null, only when the value could not be read."
                          }
                        }
                      }
                    },
                    "total": {
                      "type": "integer",
                      "description": "Number of sources returned."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidQueryParam"
          }
        }
      }
    },
    "/api/v1/sources/{id}/kept-posts": {
      "get": {
        "operationId": "listKeptPosts",
        "summary": "Which posts a keyword run swept, kept and harvested",
        "description": "Which posts a keyword search's LAST RUN swept, kept and harvested. `?include=swept` adds a row per post the run considered, with per-post `engagersSeen` and `leadsWritten` that sum to `lastRun`'s counters \u2014 the only surface that answers \"my run harvested 25 posts and wrote 21 leads; from which of them?\"\n\nThe post URNs the run swept and kept, with their permalinks. lastRun says a run scanned 25 and kept 2; this says WHICH 2, which is what separates \"the filter picked quiet posts\" from \"the capture failed\" when engagersSeen is 0. The URN is the activity URN POST /api/v1/post/reactions takes as `postUrn`, so a caller reading `postsKept: 2, engagersSeen: 0` can pull those two posts' reactions and check for themselves \u2014 that round trip is what this endpoint is for.\n\nA SEPARATE ENDPOINT ON PURPOSE. GET /api/v1/sources returns every source on every call, and an unfiltered run keeps everything it scans \u2014 up to 2,000 URNs from one run, tens of KB with the URLs. Putting that on the list response would make every caller pay for a diagnostic; ask for it here instead.\n\n`?include=swept` RETURNS EVERY POST THE RUN CONSIDERED, not only the ones it kept, under `sweptPosts` \u2014 a DIFFERENT KEY, so a rejected post can never be read out of `keptPosts`. Each row carries `outcome` and its own per-post numbers. `scanned`: the run stopped before a completed AI verdict or has no verdict for this post \u2014 an unfiltered run never produces this value, because it keeps everything it scans. `kept`: kept but NEVER REACHED, because a cap stopped the loop or its capture failed \u2014 there is no stored post, so /post/reactions answers 404 for it and `url` is null. `harvested`: captured, and the only outcome that can carry non-zero numbers. `discarded`: either the boolean expression rejected it before AI, or a completed AI filter explicitly rejected it. `reason` names the failed expression clause (for example `missing phrase \"sales ops\"` or `contains recruiter`) or says `AI filter rejected this post`. THE PER-POST NUMBERS SUM TO `lastRun`: rows = `postsScanned`, `kept` + `harvested` rows = `postsKept`, `harvested` rows = `postsHarvested`, `discardedByExpression` counts only expression-discarded rows, not AI rejects, and sum(`engagersSeen`) / sum(`engagersDropped`) / sum(`engagersDuplicate`) / sum(`leadsWritten`) are that run's four counters. That is how `harvested 25, leadsWritten 21` becomes `these five posts wrote all 21, and these twenty wrote none`: read `leadsWritten` per row, then `engagersSeen` on the zero-lead rows to tell a QUIET post (0 seen) from a LOSSY capture (people seen, none usable). On a run-scoped answer the four numbers are ALWAYS present, including 0; on the prompt-scoped fallback they are ABSENT, because nothing measured them \u2014 and an absent number is not a zero. \u26a0 `?include=swept` HAS NO FALLBACK: a run that recorded no per-post list answers `sweptPosts: null` with `reason` rather than widening to the prompt. `include=kept` is the default and is unchanged.\n\n`scope` SAYS WHICH QUESTION WAS ANSWERED, and a caller comparing the list against `postsKept` must read it. \"run\" is the list that run itself recorded, in the order it walked them. \"prompt\" is the fallback for a run that recorded none: every post kept under the search's CURRENT prompt, across every run of that prompt \u2014 a WIDER set, which will not match `postsKept`, and which is empty for a search with no AI filter.\n\n\u26a0 `keptPosts` is NULL, never an empty array, when nothing recorded a kept list \u2014 with `reason` saying so. That is a `scope: \"prompt\"` answer: a run with no AI filter decides nothing, and a run from before the verdict fix recorded nothing. Both KEPT posts, so [] would falsely claim they kept none. An EMPTY ARRAY is a different and real answer, and it only ever appears with `scope: \"run\"`: that run reached its filter and the filter rejected everything, which `postsKept: 0` says too.\n\nDISCARDS ARE ROWS, AND A RUN THAT KEPT NOTHING STILL RETURNS THEM. With `?include=swept`, every post the run considered and rejected is its own row: `outcome: \"discarded\"`, its `urn`, its `url` (the post's LinkedIn permalink as the search returned it \u2014 nothing was stored for a discarded post, so /post/reactions still answers 404 for that URN) and a `reason`. For the boolean expression the reason is the first clause the post failed, in the words of the expression: `missing phrase \"sales ops\"` (a quoted phrase not present as adjacent words), `missing hiring` (a required term absent) or `contains recruiter` (an excluded term present). For the AI filter it is `AI filter rejected this post`, followed by the provider and model whose verdict it was when that was recorded \u2014 `AI filter rejected this post (openai, gpt-4o-mini)`; the model answers only keep or reject, so there is no per-post rationale beyond that. `keptPosts` and `sweptPosts` are therefore different answers for such a run: `keptPosts` is [] and `sweptPosts` still carries one row per discard \u2014 a run that kept nothing is NEVER an empty swept list if its expression or AI filter rejected anything. The rows reconcile with `lastRun`: on a run with no AI filter, `postsScanned` = `postsKept` + `discardedByExpression`; with one, the other `discarded` rows (the ones whose reason starts `AI filter rejected this post`) are its rejections. \u26a0 `unlistedDiscards` appears only when `lastRun.discardedByExpression` counts posts this list has no row for: a run recorded before 2026-09-21 counted its discards but did not list them (and counted only the survivors in `postsScanned`), and a list at its 2000-row bound keeps the posts the run kept first and loses the tail of its discards. Those rows were never stored and are not reconstructed; a run recorded since then lists every discard.\n\nA `url` of null means the post was kept but never HARVESTED \u2014 a cap stopped the run before it reached that post \u2014 so nothing was stored for it and POST /api/v1/post/reactions answers 404 for that URN. It is the per-post spelling of lastRun's postsKept vs postsHarvested.\n\nEACH ROW ALSO SAYS WHAT THE POST IS, so a caller can triage posts WITHOUT BUYING THEIR ENGAGERS: `author` { `name`, `url`, `headline` }, `commentsCount`, `totalReactionCount`, `contentType`, `postedAt` and `postedAtTimestamp` \u2014 the names and types GET /api/v1/profile/{username}/posts already uses. They are what the keyword search reported when the run found the post (a company page has a name and a URL and no headline); `postedAt` is the instant the post's activity URN encodes, because the search result carries no time. They are present on EVERY row, on every `scope` and `include`, as null where nothing recorded them: on a row the boolean expression discarded (those posts are not recorded, to keep each run's record small), and on every post found before the September 2026 worker release that added them (not back-filled). A null is never a zero: `commentsCount: 0` is a post nobody had commented on, `commentsCount: null` one the search said nothing about.",
        "tags": [
          "Leads"
        ],
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The keyword source's id, as returned by GET /api/v1/sources. A person, company or post id is a 404: those kinds have no verdicts and never will."
          },
          {
            "name": "include",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "kept",
                "swept"
              ],
              "default": "kept"
            },
            "description": "Which SET to return. \"kept\" (default) returns the posts the run kept, under `keptPosts`. \"swept\" returns every post the run considered, under `sweptPosts`, each with its outcome and its own engager and lead counts. For a search with no AI filter the two are the same set. An unrecognised value is a 400, not a silently narrowed answer."
          }
        ],
        "responses": {
          "200": {
            "description": "The posts the run kept, or null when nothing recorded a kept list.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "sourceId": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "scope": {
                      "type": "string",
                      "enum": [
                        "run",
                        "prompt"
                      ],
                      "description": "Which question this answer answers. \"run\": the list the LAST RUN recorded, in the order it walked the posts \u2014 the same set `postsKept` counts. \"prompt\": the fallback for a run that recorded none, being every post kept under the search's CURRENT prompt across every run of it \u2014 a wider set that will not match `postsKept`, and the only scope on which `keptPosts` can be null.",
                      "example": "run"
                    },
                    "include": {
                      "type": "string",
                      "enum": [
                        "kept",
                        "swept"
                      ],
                      "description": "Which set this answer carries, echoing the `include` query parameter (`kept` when it was omitted). PRESENT ON EVERY ANSWER, including the ones whose list is null, so a caller never has to infer which question was answered from which key happens to be populated.",
                      "example": "kept"
                    },
                    "keptPosts": {
                      "type": "array",
                      "nullable": true,
                      "description": "Null ONLY with scope \"prompt\", when nothing recorded a kept list \u2014 see `reason`. An empty array appears only with scope \"run\" and is a measured answer: that run kept nothing.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "urn": {
                            "type": "string",
                            "description": "The activity URN, in exactly the form POST /api/v1/post/reactions accepts as `postUrn`.",
                            "example": "urn:li:activity:7502765357032456193"
                          },
                          "url": {
                            "type": "string",
                            "nullable": true,
                            "description": "The post's permalink, or null when the run kept this post but never harvested it \u2014 in which case /post/reactions answers 404 for its URN, because nothing was stored for it."
                          },
                          "outcome": {
                            "type": "string",
                            "enum": [
                              "kept",
                              "harvested"
                            ],
                            "description": "`kept` (kept and never reached \u2014 no stored post, `url` null) or `harvested` (captured). A discarded or undecided post is never in this list. PRESENT ONLY on a `scope: \"run\"` answer served from the run's per-post record (the same row `include=swept` returns, filtered to what was kept); ABSENT on the older run and prompt fallbacks, which recorded no outcome \u2014 an absent value is not a zero."
                          },
                          "reason": {
                            "type": "string",
                            "nullable": true,
                            "description": "Always null on this list \u2014 only a `discarded` row carries a reason, and discarded rows are served under `sweptPosts`. PRESENT ONLY on a `scope: \"run\"` answer served from the run's per-post record (the same row `include=swept` returns, filtered to what was kept); ABSENT on the older run and prompt fallbacks, which recorded no outcome \u2014 an absent value is not a zero."
                          },
                          "engagersSeen": {
                            "type": "integer",
                            "description": "This post's own engagersSeen, as on the swept row. PRESENT ONLY on a `scope: \"run\"` answer served from the run's per-post record (the same row `include=swept` returns, filtered to what was kept); ABSENT on the older run and prompt fallbacks, which recorded no outcome \u2014 an absent value is not a zero."
                          },
                          "engagersDropped": {
                            "type": "integer",
                            "description": "This post's own engagersDropped, as on the swept row. PRESENT ONLY on a `scope: \"run\"` answer served from the run's per-post record (the same row `include=swept` returns, filtered to what was kept); ABSENT on the older run and prompt fallbacks, which recorded no outcome \u2014 an absent value is not a zero."
                          },
                          "engagersDuplicate": {
                            "type": "integer",
                            "description": "This post's own engagersDuplicate, as on the swept row. PRESENT ONLY on a `scope: \"run\"` answer served from the run's per-post record (the same row `include=swept` returns, filtered to what was kept); ABSENT on the older run and prompt fallbacks, which recorded no outcome \u2014 an absent value is not a zero."
                          },
                          "leadsWritten": {
                            "type": "integer",
                            "description": "This post's own leadsWritten, as on the swept row. PRESENT ONLY on a `scope: \"run\"` answer served from the run's per-post record (the same row `include=swept` returns, filtered to what was kept); ABSENT on the older run and prompt fallbacks, which recorded no outcome \u2014 an absent value is not a zero."
                          },
                          "totalReactionCount": {
                            "type": "integer",
                            "nullable": true,
                            "description": "Reactions on the post AS THE KEYWORD SEARCH REPORTED THEM when the run found it (the result's `numReactions`) \u2014 a count, not the people, under the same name and type GET /api/v1/profile/{username}/posts uses. Refreshed only when a later run finds the post again (a kept post the budget never reached is re-found); never a later live reading. NULL, not 0, when the search stated no count; NULL on every row nothing recorded it for: a post the search's boolean expression discarded (those posts are not recorded, to keep each run's record small) and every post found before the worker release that added these fields (September 2026; not back-filled). PRESENT ON EVERY ROW OF EVERY ANSWER, the older run and prompt fallbacks included: unlike `outcome` and the four numbers it belongs to the POST, not to a run.",
                            "example": 108
                          },
                          "commentsCount": {
                            "type": "integer",
                            "nullable": true,
                            "description": "Comments on the post as the keyword search reported them when the run found it (`numComments`) \u2014 a count, not the commenters. 0 is a stated zero, a post nobody had commented on; NULL is a post the search said nothing about, and is never read as 0. NULL on every row nothing recorded it for: a post the search's boolean expression discarded (those posts are not recorded, to keep each run's record small) and every post found before the worker release that added these fields (September 2026; not back-filled). PRESENT ON EVERY ROW OF EVERY ANSWER, the older run and prompt fallbacks included: unlike `outcome` and the four numbers it belongs to the POST, not to a run.",
                            "example": 16
                          },
                          "contentType": {
                            "type": "string",
                            "nullable": true,
                            "enum": [
                              "VIDEO",
                              "IMAGE",
                              "JOB",
                              "LIVE_VIDEO",
                              "DOCUMENT",
                              "COLLABORATIVE_ARTICLE",
                              null
                            ],
                            "description": "The post's kind, read from the search result's media exactly as GET /api/v1/profile/{username}/posts reads it \u2014 the same vocabulary as the keyword search's own contentType filter. NULL for a text post and for media with no value in this vocabulary (an article, a poll); NULL on every row nothing recorded it for: a post the search's boolean expression discarded (those posts are not recorded, to keep each run's record small) and every post found before the worker release that added these fields (September 2026; not back-filled). PRESENT ON EVERY ROW OF EVERY ANSWER, the older run and prompt fallbacks included: unlike `outcome` and the four numbers it belongs to the POST, not to a run.",
                            "example": "VIDEO"
                          },
                          "postedAt": {
                            "type": "string",
                            "format": "date-time",
                            "nullable": true,
                            "description": "When the post was created, ISO 8601. \u26a0 THE KEYWORD SEARCH RESULT HAS NO TIME FIELD: this is the instant the post's activity URN encodes (a LinkedIn activity id carries its creation time, in milliseconds, in its top 41 bits), recorded by the run. Never `firstSeenAt` and never the run's own time. NULL on every row nothing recorded it for: a post the search's boolean expression discarded (those posts are not recorded, to keep each run's record small) and every post found before the worker release that added these fields (September 2026; not back-filled). PRESENT ON EVERY ROW OF EVERY ANSWER, the older run and prompt fallbacks included: unlike `outcome` and the four numbers it belongs to the POST, not to a run.",
                            "example": "2026-02-18T18:46:45.750Z"
                          },
                          "postedAtTimestamp": {
                            "type": "integer",
                            "nullable": true,
                            "description": "The same instant in epoch milliseconds, as on GET /api/v1/profile/{username}/posts. Null exactly when `postedAt` is.",
                            "example": 1771440405750
                          },
                          "author": {
                            "type": "object",
                            "required": [
                              "name",
                              "url",
                              "headline"
                            ],
                            "description": "WHO WROTE THE POST, from the search result's `actor` \u2014 the field for telling a company page's post from a person's, and a person's headline usually names their company. ALWAYS AN OBJECT with all three keys: a member is null when the search did not state it, and all three are null on every row nothing recorded them for (an expression discard, or a post found before the September 2026 worker release that added it). PRESENT ON EVERY ROW OF EVERY ANSWER, the older run and prompt fallbacks included: unlike `outcome` and the four numbers it belongs to the POST, not to a run.",
                            "properties": {
                              "name": {
                                "type": "string",
                                "nullable": true,
                                "description": "The author's display name: a company page's name, or a person's first and last name.",
                                "example": "Bitcoin Magazine"
                              },
                              "url": {
                                "type": "string",
                                "nullable": true,
                                "description": "The author's LinkedIn URL as the search gave it (a /company/ or /in/ URL), or, for a person the search gave no URL for, the /in/ URL of their public handle. Null when it gave neither \u2014 never built from a member URN, and never for a company page.",
                                "example": "https://www.linkedin.com/company/bitcoin-magazine/"
                              },
                              "headline": {
                                "type": "string",
                                "nullable": true,
                                "description": "A person author's LinkedIn headline when the search supplied one. Always null for a company page, which has none.",
                                "example": null
                              }
                            }
                          }
                        }
                      }
                    },
                    "sweptPosts": {
                      "type": "array",
                      "nullable": true,
                      "description": "EVERY POST THE RUN CONSIDERED, one row each, with what became of it and what it yielded. PRESENT ONLY with `include=swept` \u2014 with `include=kept` this key does not appear at all, and a rejected post therefore can never be read out of `keptPosts`. NULL, never [], when the run recorded no per-post list: this key has NO prompt-scoped fallback (`keptPosts` has one; per-post numbers cannot be reconstructed from the verdict table), so the answer is `sweptPosts: null` with `reason`. The rows SUM TO `lastRun`: rows = `postsScanned`, rows whose `outcome` is `kept` or `harvested` = `postsKept`, `harvested` rows = `postsHarvested`, `discardedByExpression` counts only expression discards; AI-rejected rows are also `discarded` but do not increment that counter, and the four counters summed across rows are `engagersSeen`, `engagersDropped`, `engagersDuplicate` and `leadsWritten`. A RUN THAT KEPT NOTHING STILL HAS ROWS HERE: `keptPosts` is [] for it, while this list carries one `discarded` row per post its expression or AI filter rejected \u2014 see `unlistedDiscards` for the only case a discard can be counted and not listed. THE `discarded` ROWS ARE WHAT MAKES A NARROW EXPRESSION READABLE: they are the near-misses, each carrying the clause it failed, and before 2026-09-21 a post an expression threw away appeared in no list on any surface and in no count either.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "urn": {
                            "type": "string",
                            "description": "The activity URN, in exactly the form POST /api/v1/post/reactions accepts as `postUrn`.",
                            "example": "urn:li:activity:7502765357032456193"
                          },
                          "url": {
                            "type": "string",
                            "nullable": true,
                            "description": "The post's permalink, or null when nothing was stored for it \u2014 which is every `scanned` and every `kept` row, and is why /post/reactions answers 404 for those URNs. On a `discarded` row it is the post's LinkedIn permalink AS THE SEARCH RETURNED IT, recorded by the run because nothing was stored \u2014 open it to check the `reason`; /post/reactions still answers 404 for that URN. When that link was not stored \u2014 the run's per-post record had to be stored without share links, or the row predates them \u2014 it is the /feed/update/ permalink built from the post's URN, so a discarded row's `url` is never null."
                          },
                          "outcome": {
                            "type": "string",
                            "enum": [
                              "scanned",
                              "kept",
                              "harvested",
                              "discarded"
                            ],
                            "description": "What became of this post. `scanned` \u2014 the run stopped before a completed AI decision, or the filter gave no verdict; a run with no AI filter never produces this value, because it keeps everything it scans. `kept` \u2014 kept and NEVER REACHED: a cap stopped the loop, or the capture itself failed, so no post row was stored, `url` is null and /post/reactions answers 404. `harvested` \u2014 captured, and the ONLY outcome that can carry non-zero counts below. `discarded` \u2014 a boolean expression rejected it before AI, or a completed AI filter explicitly rejected it; `reason` identifies the clause or says `AI filter rejected this post` (with the provider and model when recorded), and nothing was stored for it, so `url` is the permalink the search returned and /post/reactions answers 404.",
                            "example": "harvested"
                          },
                          "reason": {
                            "type": "string",
                            "nullable": true,
                            "description": "WHY A `discarded` ROW WAS DISCARDED, in the few words that name the failing expression clause (`missing phrase \"sales ops\"`, `missing hiring`, `contains recruiter`) or the completed AI verdict (`AI filter rejected this post`, followed by the provider and model that judged it when recorded: `AI filter rejected this post (openai, gpt-4o-mini)` \u2014 match on the prefix). An expression reason is one of three shapes: `missing phrase \"<phrase>\"`, `missing <term>` or `contains <term>`, each naming the term as it was typed in the expression. NULL \u2014 not absent \u2014 on every other outcome, so a caller never has to read an absent key as \"not discarded\". ALWAYS PRESENT on a run-scoped answer. It names a TERM and never quotes the post, and for a multi-branch expression it is the FIRST `OR` branch's first failure: the branches are alternatives, the post failed every one of them, and the first is the one the reader wrote first.",
                            "example": "missing phrase \"sales ops\""
                          },
                          "engagersSeen": {
                            "type": "integer",
                            "description": "Engagers this post's capture received. ALWAYS PRESENT on a run-scoped answer, 0 included \u2014 a measured 0 on a `harvested` row is a QUIET post, as against a `harvested` row with people seen and no leads, which is a lossy capture. 0 on every `scanned` and `kept` row, which were never captured."
                          },
                          "engagersDropped": {
                            "type": "integer",
                            "description": "Engagers this post yielded that could not become a lead \u2014 no identity at all, or an organisation page. ALWAYS PRESENT on a run-scoped answer, 0 included."
                          },
                          "engagersDuplicate": {
                            "type": "integer",
                            "description": "Rows this post produced that collided with a lead the team already held \u2014 already captured, so free rather than lost. ALWAYS PRESENT on a run-scoped answer, 0 included."
                          },
                          "leadsWritten": {
                            "type": "integer",
                            "description": "Lead rows this post actually inserted, equal to the credits it spent. THIS IS THE COLUMN THAT ANSWERS \"my run harvested 25 posts and wrote 21 leads; from which of them?\" \u2014 sort on it, then read `engagersSeen` on the zero-lead rows. ALWAYS PRESENT on a run-scoped answer, 0 included."
                          },
                          "totalReactionCount": {
                            "type": "integer",
                            "nullable": true,
                            "description": "Reactions on the post AS THE KEYWORD SEARCH REPORTED THEM when the run found it (the result's `numReactions`) \u2014 a count, not the people, under the same name and type GET /api/v1/profile/{username}/posts uses. Refreshed only when a later run finds the post again (a kept post the budget never reached is re-found); never a later live reading. NULL, not 0, when the search stated no count; NULL on every row nothing recorded it for: a post the search's boolean expression discarded (those posts are not recorded, to keep each run's record small) and every post found before the worker release that added these fields (September 2026; not back-filled). ALWAYS PRESENT on a run-scoped answer.",
                            "example": 108
                          },
                          "commentsCount": {
                            "type": "integer",
                            "nullable": true,
                            "description": "Comments on the post as the keyword search reported them when the run found it (`numComments`) \u2014 a count, not the commenters. 0 is a stated zero, a post nobody had commented on; NULL is a post the search said nothing about, and is never read as 0. NULL on every row nothing recorded it for: a post the search's boolean expression discarded (those posts are not recorded, to keep each run's record small) and every post found before the worker release that added these fields (September 2026; not back-filled). ALWAYS PRESENT on a run-scoped answer.",
                            "example": 16
                          },
                          "contentType": {
                            "type": "string",
                            "nullable": true,
                            "enum": [
                              "VIDEO",
                              "IMAGE",
                              "JOB",
                              "LIVE_VIDEO",
                              "DOCUMENT",
                              "COLLABORATIVE_ARTICLE",
                              null
                            ],
                            "description": "The post's kind, read from the search result's media exactly as GET /api/v1/profile/{username}/posts reads it \u2014 the same vocabulary as the keyword search's own contentType filter. NULL for a text post and for media with no value in this vocabulary (an article, a poll); NULL on every row nothing recorded it for: a post the search's boolean expression discarded (those posts are not recorded, to keep each run's record small) and every post found before the worker release that added these fields (September 2026; not back-filled). ALWAYS PRESENT on a run-scoped answer.",
                            "example": "VIDEO"
                          },
                          "postedAt": {
                            "type": "string",
                            "format": "date-time",
                            "nullable": true,
                            "description": "When the post was created, ISO 8601. \u26a0 THE KEYWORD SEARCH RESULT HAS NO TIME FIELD: this is the instant the post's activity URN encodes (a LinkedIn activity id carries its creation time, in milliseconds, in its top 41 bits), recorded by the run. Never `firstSeenAt` and never the run's own time. NULL on every row nothing recorded it for: a post the search's boolean expression discarded (those posts are not recorded, to keep each run's record small) and every post found before the worker release that added these fields (September 2026; not back-filled). ALWAYS PRESENT on a run-scoped answer.",
                            "example": "2026-02-18T18:46:45.750Z"
                          },
                          "postedAtTimestamp": {
                            "type": "integer",
                            "nullable": true,
                            "description": "The same instant in epoch milliseconds, as on GET /api/v1/profile/{username}/posts. Null exactly when `postedAt` is.",
                            "example": 1771440405750
                          },
                          "author": {
                            "type": "object",
                            "required": [
                              "name",
                              "url",
                              "headline"
                            ],
                            "description": "WHO WROTE THE POST, from the search result's `actor` \u2014 the field for telling a company page's post from a person's, and a person's headline usually names their company. ALWAYS AN OBJECT with all three keys: a member is null when the search did not state it, and all three are null on every row nothing recorded them for (an expression discard, or a post found before the September 2026 worker release that added it). ALWAYS PRESENT on a run-scoped answer.",
                            "properties": {
                              "name": {
                                "type": "string",
                                "nullable": true,
                                "description": "The author's display name: a company page's name, or a person's first and last name.",
                                "example": "Bitcoin Magazine"
                              },
                              "url": {
                                "type": "string",
                                "nullable": true,
                                "description": "The author's LinkedIn URL as the search gave it (a /company/ or /in/ URL), or, for a person the search gave no URL for, the /in/ URL of their public handle. Null when it gave neither \u2014 never built from a member URN, and never for a company page.",
                                "example": "https://www.linkedin.com/company/bitcoin-magazine/"
                              },
                              "headline": {
                                "type": "string",
                                "nullable": true,
                                "description": "A person author's LinkedIn headline when the search supplied one. Always null for a company page, which has none.",
                                "example": null
                              }
                            }
                          }
                        }
                      }
                    },
                    "unlistedDiscards": {
                      "type": "object",
                      "description": "PRESENT ONLY with `include=swept`, and only when the run's `lastRun.discardedByExpression` counts posts that `sweptPosts` has no `discarded` row for (AI rejections are not part of that counter and are not compared). ABSENT whenever the list is complete, which is every run recorded since 2026-09-21 that fits the list's 2000-row bound. It exists because a list without these rows, served bare, is indistinguishable from a run that never saw those posts.",
                      "properties": {
                        "count": {
                          "type": "integer",
                          "description": "How many of the posts `discardedByExpression` counts are not listed.",
                          "example": 10
                        },
                        "reason": {
                          "type": "string",
                          "description": "Which of the two causes applies: the run was recorded before discarded posts were listed (a worker before 2026-09-21, which also counted only the survivors in `postsScanned`), or the list reached its 2000-row bound, which keeps the posts the run kept first and drops the tail of its discards. The missing URNs and reasons were never stored and are not reconstructed.",
                          "example": "this run was recorded before discarded posts were listed (worker releases before 2026-09-21): lastRun.discardedByExpression counts 10 posts this list has no row for, and their URNs and reasons were not stored. A run recorded since then lists every discard with its reason."
                        }
                      }
                    },
                    "reason": {
                      "type": "string",
                      "description": "Why the list is null \u2014 present only when `keptPosts` (or, with `include=swept`, `sweptPosts`) is null.",
                      "example": "no verdicts recorded for this run"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "No keyword search with that id on this team."
          }
        }
      }
    },
    "/api/v1/sources/{id}/sync": {
      "get": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Check sync status for any tracked source, by source id",
        "operationId": "getSourceSyncStatus",
        "description": "Check ANY tracked source's background capture sync: stage, progress counts, and whether it has finished. ADDRESSED BY SOURCE ID, AND IT WORKS FOR EVERY KIND OF SOURCE \u2014 a person, a company page, a TRACKED POST or a KEYWORD SEARCH. The per-kind routes are keyed by whatever identifier that kind happens to have (/api/v1/{profile|company}/{username}/... by LinkedIn handle, /api/v1/keyword/{id}/... by source id), which is why a tracked post \u2014 whose identifier is an activity URN \u2014 had no route at all. It was never the SETTING that was missing: a tracked post is a tracked_profiles row like any other, its webhook fires through the same path, its leads are scored against the same ICP rules, and POST /api/v1/post/track has always returned a `syncId` from a real sync job. Only the addressing was. THIS IS HOW YOU FOLLOW A TRACKED POST. POST /api/v1/post/track answers with a `syncId` \u2014 a real sync_jobs row, queued the same way a profile's first sync is \u2014 and until this route existed there was nothing to hand that id to, so \"is my post capturing?\" was unanswerable from the API. THE BODY IS THE SAME LIFECYCLE OBJECT the per-kind /sync routes return, field for field, because it is the same code: what differs between the routes is how you addressed the source and the identity fields you get back, never what \"finished\" means. THE LIFECYCLE INCLUDES ENRICHMENT, AND SO DOES `isFinal`: capture writes the engagement records, then new people for this source are enriched with firmographics for one credit each; their repeat engagement rows are free, and that second half normally runs on well after the capture itself has ended. `state` therefore stays `enriching` and `isFinal` stays false while leads are still being enriched and billed \u2014 read `enrichment` for the pending / completed / failed / skipped breakdown, and `capture` when all you need to know is that collection finished.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The SOURCE id, as GET /api/v1/sources returns it in each source's `id`. Every kind of source has one \u2014 that is the whole point of this family: a person and a company page are also reachable by handle, a keyword search's handle is its free-text search terms, and a tracked post's is an activity URN, so the id is the only identifier all four share."
          }
        ],
        "responses": {
          "200": {
            "description": "The source's current sync lifecycle.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "sourceId",
                    "type",
                    "username",
                    "state",
                    "isFinal",
                    "capture",
                    "enrichment",
                    "progress"
                  ],
                  "properties": {
                    "sourceId": {
                      "type": "string",
                      "format": "uuid",
                      "description": "The source id you addressed \u2014 the same value GET /api/v1/sources reports as `id`."
                    },
                    "type": {
                      "type": "string",
                      "enum": [
                        "person",
                        "company",
                        "post",
                        "keyword"
                      ],
                      "description": "The kind of source this id turned out to be, spelled exactly as GET /api/v1/sources spells it. You do not have to know the kind to call these routes; this is how you learn it."
                    },
                    "username": {
                      "type": "string",
                      "description": "The row's stored label, which means something different per kind: a LinkedIn handle for a person or company page, the activity URN for a tracked post, and the joined search terms for a keyword search. Reported, never used to address these routes."
                    },
                    "syncId": {
                      "type": "string",
                      "format": "uuid",
                      "nullable": true,
                      "description": "The background sync run. Null when no sync has ever been queued."
                    },
                    "state": {
                      "type": "string",
                      "enum": [
                        "queued",
                        "collecting_posts",
                        "collecting_engagements",
                        "enriching",
                        "completed",
                        "failed",
                        "paused"
                      ],
                      "description": "Stage of the source's background lifecycle, capture AND enrichment. Mirrors the dashboard's stages: collecting_posts \u2192 collecting_engagements \u2192 enriching. `enriching` also covers enrichment that continues after the capture run has finished \u2014 the usual case, since enrichment runs off the capture job \u2014 so a source stays `enriching` until nothing is left in `enrichment.pending`. `paused` means enriching credits ran out; it resumes automatically when credits return."
                    },
                    "description": {
                      "type": "string",
                      "description": "One-line, human-readable explanation of `state`."
                    },
                    "isFinal": {
                      "type": "boolean",
                      "description": "True when the WHOLE lifecycle is over \u2014 capture finished and no lead is still queued for enrichment \u2014 so nothing further will change without a new run: stop polling. It is NOT set while enrichment is still running and still spending credits. For capture alone, read `capture.isFinal`."
                    },
                    "queuedAt": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true
                    },
                    "startedAt": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true
                    },
                    "updatedAt": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true
                    },
                    "completedAt": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true,
                      "description": "When the run finished, capture and enrichment both. Null whenever `isFinal` is false, so it can never read as \"done\" over leads that are still being enriched. For the capture run's own stamp, read `capture.completedAt`."
                    },
                    "capture": {
                      "type": "object",
                      "description": "The capture half on its own: posts walked and engagement records written, saying nothing about enriching them. `state`, `isFinal` and `completedAt` are exactly what the top-level fields of the same names reported before they were corrected to describe the whole lifecycle, so a caller that only ever needed \"collection finished\" reads `capture.isFinal` here. `stoppedBy`, `creditsSpent`, `leadRowsCaptured` and `coverage` beside them say WHY the run ended where it did, what it was charged, how many lead rows it wrote and how much of what the provider declared it holds \u2014 the answer to \"why did this source come back small\", which progress counts alone could never give.",
                      "properties": {
                        "state": {
                          "type": "string",
                          "enum": [
                            "queued",
                            "collecting_posts",
                            "collecting_engagements",
                            "enriching",
                            "completed",
                            "failed",
                            "paused"
                          ],
                          "description": "The capture run's own stage."
                        },
                        "isFinal": {
                          "type": "boolean",
                          "description": "True once the capture run reached a terminal state, whatever enrichment is still doing."
                        },
                        "completedAt": {
                          "type": "string",
                          "format": "date-time",
                          "nullable": true,
                          "description": "When the capture run finished. Enrichment normally continues past this moment."
                        },
                        "stoppedBy": {
                          "type": "string",
                          "nullable": true,
                          "enum": [
                            "exhausted",
                            "credits",
                            "capture_empty",
                            "provider_limit",
                            null
                          ],
                          "description": "WHY THE CAPTURE RUN ENDED, or `null`. `credits` means the source's own `creditCapPerSync` was reached and the run stopped THERE \u2014 there was more to collect, and raising the limit would get it. `exhausted` means the run collected every engagement it found, so a bigger limit would change nothing. `capture_empty` means it collected POSTS and captured NOBODY from them \u2014 not a quiet week and not an ending anybody chose: between 12 and 14 September 2026 the upstream engager endpoints answered 200 with no rows for three days and every sync reported `completed` with `error: null` while leads/day went 3,150 to 0. `errorCode` carries the same marker and `error` the sentence \"Harvested N posts, captured nobody.\" A sync that collected NO posts never reports it. `provider_limit` means the DATA PROVIDER stopped serving a post's reactions with at least one full page (50) of them still declared, and no cap was the reason: the capture holds materially fewer people than the post declares and NO LIMIT WILL GET THE REST, because the provider will not serve them. Measured 29 September 2026: a post declaring 3,490 reactions stopped at about 1,100 people (22 pages of ~50, then only empty pages, every one still declaring 3,490). `coverage` beside it gives the declared and captured counts. The rule counts the rows the provider SERVED, reactions it sends with no identity included, against the declared total, so a post served in full still ends `exhausted` (418 declared, 404 identifiable and 14 anonymous is complete); it is the rule POST /api/v1/post/reactions uses for `exhausted: false`, so the two never disagree about one post. A run the credit cap stopped reports `credits`, never this. Before this field the two were the SAME RESPONSE: \"collected 100 because that was everything\" and \"collected 100 because 100 was the cap\" both arrived as progress counts with nothing to tell them apart. THE WORDS ARE `lastRun.stoppedBy`'s ON GET /api/v1/sources, deliberately \u2014 the keyword side closed this same gap first, and a second vocabulary for one question is how a caller ends up writing two branches for one fact. \u26a0 `null` IS FOUR DIFFERENT THINGS AND ALL FOUR ARE HONEST: the first sync has not finished yet; the run FAILED (neither word is true of a run that broke \u2014 read `state` and `errorCode`); the source was UNTRACKED mid-run, an ending this vocabulary has no word for and which the TOP-LEVEL `stoppedBy` reports as `untracked`; or this is a KEYWORD search, whose sweep records its ending on `lastRun.stoppedBy` at GET /api/v1/sources and never here. The TOP-LEVEL `stoppedBy` on this same response names the SAME ending, spelling the cap `credit_cap` and an untracked run `untracked`."
                        },
                        "creditsSpent": {
                          "type": "integer",
                          "nullable": true,
                          "description": "WHAT THIS RUN WAS CHARGED, in credits, or `null` \u2014 the same ledger GET /api/v1/credits/usage totals, attributed to this run: the credits charged for the leads it created (one per NEW person per source \u2014 a repeat engager's row, and a lead whose enrichment failed, cost nothing), the posts a posts-only watch bought (one credit each), or, for a keyword search, what its sweep spent (the number `lastRun.creditsSpent` reports). \u26a0 CHANGED IN THIS RELEASE: it used to be the lead-ROW count, which read as an overspend (46 rows beside a `creditCap` of 30 that charged 30) and as free for a paid posts-only run (0). That count is now `leadRowsCaptured`. Enrichment charges a run's leads AFTER the capture ends, so while `isFinal` is false this is the charge SO FAR; it is settled when `isFinal` is true. PRESENT ON A FAILED RUN TOO, unlike `stoppedBy`: the leads a run wrote before it broke are still enriched and charged. `null` until the capture reaches a terminal state, and `null` \u2014 never a guessed `0` \u2014 when the charge could not be read. A `0` is a measurement."
                        },
                        "leadRowsCaptured": {
                          "type": "integer",
                          "nullable": true,
                          "description": "The lead ROWS this run wrote \u2014 what `creditsSpent` reported before this release. NOT what it cost: billing charges once per new person per source, so a repeat engager writes a row for free and a posts-only watch writes none. `creditCapPerSync` caps these rows. `null` until the capture reaches a terminal state and for a run that predates the counter; present on a failed run too. A `0` is a measurement."
                        },
                        "coverage": {
                          "type": "object",
                          "nullable": true,
                          "required": [
                            "declared",
                            "captured"
                          ],
                          "description": "DECLARED BESIDE CAPTURED: the reactions + comments the provider declared on the posts this run swept, and the leads this source now holds on those same posts \u2014 so a post the provider cut off reads `{\"declared\": 3690, \"captured\": 1147}` instead of being left to be inferred. `captured` is cumulative (a re-sync that adds 5 people to a post it already holds 1,045 of reports 1,050) and counts PEOPLE, deduplicated, while `declared` counts engagements, so even a post captured in full reads a little under (reactions with no identity, company pages, one person who both reacted and commented). The gap that matters is the one `stoppedBy: \"provider_limit\"` names. `null` until the capture is over, for a run recorded before this field, for a keyword search or a posts-only watch (which record no engagement coverage), and whenever either number is missing \u2014 half a pair is never served.",
                          "properties": {
                            "declared": {
                              "type": "integer",
                              "minimum": 0
                            },
                            "captured": {
                              "type": "integer",
                              "minimum": 0
                            }
                          }
                        }
                      }
                    },
                    "enrichment": {
                      "type": "object",
                      "description": "The source's leads by enrichment outcome. The four buckets partition `total`, so the gap between captured and enriched leads is always attributable. THIS IS WHERE THE CREDITS GO: one credit per newly enriched person per source; repeat engagement rows are free, so spend continues after capture completes. A run's billing is settled and its downstream processing done when `pending` reaches 0 \u2014 the same moment `isFinal` becomes true, `leadsReady` flips, and the run's `lead.detected` webhooks and provider integrations fire.",
                      "properties": {
                        "total": {
                          "type": "integer",
                          "description": "All leads held for this source, across runs. The same number as `progress.leadsTotal`."
                        },
                        "pending": {
                          "type": "integer",
                          "description": "Queued or in flight: work still owed, and still to be billed. Above 0 means this source is not finished, whatever the capture run says."
                        },
                        "completed": {
                          "type": "integer",
                          "description": "Enriched: carries firmographics. This is a row count, not billed credits; repeats of a person already charged for this source are free. For a source in raw mode (`enrichLeads: false`) it counts the leads captured raw and charged instead \u2014 done, never enriched, carrying no firmographics, and read with GET /api/v1/leads/raw."
                        },
                        "failed": {
                          "type": "integer",
                          "description": "Enrichment attempts exhausted. Terminal and never charged \u2014 these are reported rather than left holding the lifecycle open."
                        },
                        "skipped": {
                          "type": "integer",
                          "description": "Terminal with no data to add: the provider returned nothing for the person, or the team has no chargeable enriching plan. Never charged."
                        }
                      }
                    },
                    "progress": {
                      "type": "object",
                      "properties": {
                        "postsCollected": {
                          "type": "integer",
                          "description": "Posts found on the source in this run."
                        },
                        "engagementsCaptured": {
                          "type": "integer",
                          "description": "Engagements (likes + comments) captured in this run."
                        },
                        "leadsTotal": {
                          "type": "integer",
                          "description": "All leads currently held for this source, across runs."
                        },
                        "leadsEnriched": {
                          "type": "integer",
                          "description": "Of those, how many carry enriched firmographics. Unchanged, and equal to `enrichment.completed`; `enrichment` accounts for the rest. For a source in raw mode (`enrichLeads: false`) it counts the leads captured raw and charged, which carry none."
                        }
                      }
                    },
                    "stoppedBy": {
                      "type": "string",
                      "nullable": true,
                      "enum": [
                        "credit_cap",
                        "exhausted",
                        "capture_empty",
                        "provider_limit",
                        "untracked",
                        null
                      ],
                      "description": "HOW THIS RUN ENDED \u2014 the SAME ending `capture.stoppedBy` names, so the two fields never disagree. `credit_cap` means the source's own per-sync credit limit (tracked_profiles.credit_cap, reported as `creditCapPerSync` on GET /api/v1/sources) was reached and the run stopped there \u2014 an ORDINARY ending, not a failure: `state` stays `completed`, `errorCode` is null, nothing broke. It is the ending `capture.stoppedBy` spells `credits`: both spellings were published first and callers branch on each, so neither is renamed. `exhausted`, `capture_empty` and `provider_limit` mean exactly what they mean on `capture.stoppedBy`. `untracked` means the source was untracked while the run was in flight and the sweep was abandoned \u2014 an ending the capture vocabulary has no word for, so `capture.stoppedBy` is `null` beside it. `null` means there is no ending to name: no run has finished yet, the run FAILED (read `state` and `errorCode`), or this is a KEYWORD sweep, which records how it ended on keyword_searches.last_run_stopped_by and GET /api/v1/sources reports as `lastRun.stoppedBy`. \u26a0 CHANGED IN THIS RELEASE: this field used to be `null` for every ordinary run \u2014 beside `capture.stoppedBy: \"exhausted\"`, and beside a post the provider had cut off at a third of its reactions \u2014 so a caller reading only this field saw no ending at all. \u26a0\ufe0f THOSE TWO FIELDS ARE NOT THE SAME LIST: `lastRun.stoppedBy` answers \"how did this keyword sweep end\" (budget / credits / exhausted / error / ai_error / team_cap / lead_cap); this one answers how a person, company-page or tracked-post CAPTURE ended. Read from the same job row the rest of this response describes, so it is about the CURRENT run and not about the source's history. PER SYNC, EVERY SYNC: it bounds EACH sync of that one person, company page or post \u2014 the capture cap counts lead rows, while billing charges once per new person per source \u2014 not the first pull only and not the life of the source, and there is NO ESTIMATE, NO CONFIRMATION GATE AND NO TEAM CEILING behind it: nothing prices a sync before you set the cap, raising or lowering it never needs `confirmSpend`, and the team's `dailyCeiling` does not count a credit of it. \u26a0\ufe0f A KEYWORD SEARCH'S `creditCap` IS THE OTHER FIELD AND HAS ALL THREE: it is the daily bound on a RECURRING SWEEP, POST /api/v1/keyword/estimate prices it as `estimatedDailyMax`, `confirmSpend` gates a create or a raise with a `409 spend_confirmation_required`, and the team's `dailyCeiling` stops it \u2014 and `dailyCeiling` COUNTS KEYWORD SPEND ONLY, so no number of profile syncs can ever reach it."
                    },
                    "lastSyncedAt": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true
                    },
                    "nextSyncAt": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true,
                      "description": "When the next scheduled sync is due (daily cadence). null while a sync is currently running \u2014 there is no next run scheduled until the active one finishes; use `state`/`isFinal` to see it is in progress. null for a source that is not ACTIVE \u2014 untracked, or paused after repeated not-found errors \u2014 always: nothing will run it (untracking parks its schedule, and a stored value is never served for a source that is not active)."
                    },
                    "leadsReady": {
                      "type": "boolean",
                      "description": "True once leads are ready to read."
                    },
                    "error": {
                      "type": "string",
                      "nullable": true,
                      "description": "Human-readable failure reason when `state` is `failed`. Owned Cornersight text - it never contains the upstream data provider's raw response. Wording may change; branch on `errorCode`."
                    },
                    "errorCode": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "enum": [
                        "insufficient_enriching_credits",
                        "not_found",
                        "page_out_of_range",
                        "invalid_request",
                        "provider_rate_limited",
                        "provider_access_denied",
                        "provider_unavailable",
                        "internal_error",
                        "ai_error",
                        "search_term_rejected",
                        null
                      ],
                      "description": "Stable, machine-readable reason for the failure, or `null` when the sync has not failed. The values JobStatus.errorCode uses, plus two that only a KEYWORD sweep produces and that are the CUSTOMER'S to fix: `ai_error` \u2014 the search's AI filter failed on the team's own AI setup (a model the provider does not recognise, a revoked key, an exhausted quota); `error` says which and what to change, in the same words as `lastRun.reason`, and `lastRun.aiErrorCode` carries the fine-grained code. `search_term_rejected` \u2014 the data provider refused one of the search's terms (HTTP 400); `error` names the term. The same term is refused on every run, so edit the search's keywords \u2014 retrying will not help. Before this release both were reported as `internal_error` (\"retrying may succeed\"), which told the customer the problem was Cornersight's."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The path segment is not a source id (a UUID from GET /api/v1/sources), or the body is invalid. A malformed id is deliberately a 400 rather than a 404: \"that is not an id\" and \"you do not have that source\" are different answers."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "This team cannot be used (subscription inactive or blocked)."
          },
          "404": {
            "description": "No source with that id on this team \u2014 including one you UNTRACKED. Untracking is a soft delete, and a deactivated person, company or post is not readable and not actionable: its retained leads are out of GET /api/v1/leads too, and its webhook config is what a push would deliver to. A STOPPED KEYWORD SEARCH is the deliberate exception \u2014 its leads are kept and served, so it stays readable and editable here. The 404 is identical for an id that belongs to another team, so this is not an existence oracle."
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "description": "The sync state could not be read."
          }
        }
      },
      "post": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Sync a tracked person, company page or post now, by source id",
        "operationId": "syncSource",
        "description": "QUEUE A CAPTURE SYNC OF A TRACKED PERSON, COMPANY PAGE OR TRACKED POST NOW, instead of waiting for its next scheduled sync. RE-TRACKING DOES NOT RE-SYNC: POST /api/v1/enrich/profile or /enrich/company with `saveTrackedProfile: true`, and POST /api/v1/post/track, on a source that has already synced apply the settings sent and queue nothing (`syncId: null` + `syncNotQueuedReason`). This is the sync.\n\n**Cost.** Charged like any sync of that source: one enriching credit per NEW person for the source, repeat engagement free, bounded by the source's `creditCapPerSync` when it has one. There is no separate price; a second sync in a day costs what the next day's scheduled sync would.\n\n**Never stacked.** When a sync of the source is already queued, running or paused for enriching credits, nothing new is queued: the answer is `200` with `queued: false`, that job's `syncId` and `status`, and a `message`. Otherwise it is `202` with `queued: true` and the new job's `syncId`. Follow either with GET /api/v1/sources/{id}/sync.\n\n**Schedule.** The source's next scheduled sync moves to at least 24 hours from now, so the daily sync does not run it again straight after; a `nextSyncAt` already further out is left as it is.\n\n**Keyword searches are refused** with `400` and code `keyword_search_not_syncable`: a keyword search runs on its own daily schedule and each run may spend its `creditCap`, so an extra run would spend it twice in one day. There is no run-now route for a keyword search; follow its runs with GET /api/v1/keyword/{id}/sync, change it with PATCH /api/v1/keyword/{id}, and restart a stopped one with POST /api/v1/keyword/track.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The source id from GET /api/v1/sources, of a person, a company page or a tracked post."
          }
        ],
        "responses": {
          "200": {
            "description": "A sync of this source was already queued, running or paused for credits; nothing new was queued.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "sourceId",
                    "type",
                    "username",
                    "queued",
                    "syncId",
                    "status",
                    "message"
                  ],
                  "properties": {
                    "sourceId": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "type": {
                      "type": "string",
                      "enum": [
                        "person",
                        "company",
                        "post"
                      ]
                    },
                    "username": {
                      "type": "string",
                      "description": "The row's stored label: a handle for a person or company page, the activity URN for a post."
                    },
                    "queued": {
                      "type": "boolean",
                      "enum": [
                        false
                      ]
                    },
                    "syncId": {
                      "type": "string",
                      "description": "The job already in flight. GET /api/v1/sources/{id}/sync reports it."
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "pending",
                        "running",
                        "paused"
                      ]
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "202": {
            "description": "A sync was queued. It runs within seconds to minutes; follow it with GET /api/v1/sources/{id}/sync.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "sourceId",
                    "type",
                    "username",
                    "queued",
                    "syncId",
                    "status"
                  ],
                  "properties": {
                    "sourceId": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "type": {
                      "type": "string",
                      "enum": [
                        "person",
                        "company",
                        "post"
                      ]
                    },
                    "username": {
                      "type": "string"
                    },
                    "queued": {
                      "type": "boolean",
                      "enum": [
                        true
                      ]
                    },
                    "syncId": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "pending"
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The path segment is not a source id (a UUID from GET /api/v1/sources), or the source is a keyword search (`keyword_search_not_syncable`)."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "This team cannot be used (subscription inactive or blocked)."
          },
          "404": {
            "description": "No source with that id on this team \u2014 including one you untracked, which is not actionable. The 404 is identical for an id that belongs to another team."
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "description": "The sync could not be queued."
          },
          "502": {
            "description": "The source's sync jobs could not be read."
          }
        }
      }
    },
    "/api/v1/sources/{id}/webhook": {
      "get": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Get webhook configuration for any tracked source, by source id",
        "operationId": "getSourceWebhookConfig",
        "description": "Read the webhook configuration \u2014 delivery URL and flags \u2014 of any tracked source. ADDRESSED BY SOURCE ID, AND IT WORKS FOR EVERY KIND OF SOURCE \u2014 a person, a company page, a TRACKED POST or a KEYWORD SEARCH. The per-kind routes are keyed by whatever identifier that kind happens to have (/api/v1/{profile|company}/{username}/... by LinkedIn handle, /api/v1/keyword/{id}/... by source id), which is why a tracked post \u2014 whose identifier is an activity URN \u2014 had no route at all. It was never the SETTING that was missing: a tracked post is a tracked_profiles row like any other, its webhook fires through the same path, its leads are scored against the same ICP rules, and POST /api/v1/post/track has always returned a `syncId` from a real sync job. Only the addressing was. Same column, same delivery path and same four flags for every kind: a tracked post's and a keyword search's `lead.detected` have always fired, and only reading and setting them from outside the dashboard is new. FOR A TRACKED POST, `webhookUrl`, `icpOnly` and `autoSend` apply to its `lead.detected` exactly as for any source (`autoSend: false` holds its leads for POST /api/v1/sources/{id}/push); `syncEvents` does not, because no sync lifecycle event is ever sent for a post, so the PUT refuses `syncEvents: true` on one.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The SOURCE id, as GET /api/v1/sources returns it in each source's `id`. Every kind of source has one \u2014 that is the whole point of this family: a person and a company page are also reachable by handle, a keyword search's handle is its free-text search terms, and a tracked post's is an activity URN, so the id is the only identifier all four share."
          }
        ],
        "responses": {
          "200": {
            "description": "The source's current webhook configuration.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "sourceId",
                    "type",
                    "username",
                    "webhook"
                  ],
                  "properties": {
                    "sourceId": {
                      "type": "string",
                      "format": "uuid",
                      "description": "The source id you addressed \u2014 the same value GET /api/v1/sources reports as `id`."
                    },
                    "type": {
                      "type": "string",
                      "enum": [
                        "person",
                        "company",
                        "post",
                        "keyword"
                      ],
                      "description": "The kind of source this id turned out to be, spelled exactly as GET /api/v1/sources spells it. You do not have to know the kind to call these routes; this is how you learn it."
                    },
                    "username": {
                      "type": "string",
                      "description": "The row's stored label, which means something different per kind: a LinkedIn handle for a person or company page, the activity URN for a tracked post, and the joined search terms for a keyword search. Reported, never used to address these routes."
                    },
                    "webhook": {
                      "type": "object",
                      "properties": {
                        "webhookUrl": {
                          "type": "string",
                          "nullable": true,
                          "description": "Where leads are POSTed. Null = no webhook."
                        },
                        "icpOnly": {
                          "type": "boolean",
                          "description": "Only deliver leads matching the source's ICP filter."
                        },
                        "autoSend": {
                          "type": "boolean",
                          "description": "Auto-deliver new leads (true) or deliver only on explicit push (false)."
                        },
                        "syncEvents": {
                          "type": "boolean",
                          "description": "Also send signed sync.completed/sync.failed lifecycle callbacks."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The path segment is not a source id (a UUID from GET /api/v1/sources), or the body is invalid. A malformed id is deliberately a 400 rather than a 404: \"that is not an id\" and \"you do not have that source\" are different answers."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "No source with that id on this team \u2014 including one you UNTRACKED. Untracking is a soft delete, and a deactivated person, company or post is not readable and not actionable: its retained leads are out of GET /api/v1/leads too, and its webhook config is what a push would deliver to. A STOPPED KEYWORD SEARCH is the deliberate exception \u2014 its leads are kept and served, so it stays readable and editable here. The 404 is identical for an id that belongs to another team, so this is not an existence oracle."
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "put": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Configure the webhook for any tracked source, by source id",
        "operationId": "setSourceWebhookConfig",
        "description": "Configure how leads are delivered OUT of Cornersight for ANY tracked source. ADDRESSED BY SOURCE ID, AND IT WORKS FOR EVERY KIND OF SOURCE \u2014 a person, a company page, a TRACKED POST or a KEYWORD SEARCH. The per-kind routes are keyed by whatever identifier that kind happens to have (/api/v1/{profile|company}/{username}/... by LinkedIn handle, /api/v1/keyword/{id}/... by source id), which is why a tracked post \u2014 whose identifier is an activity URN \u2014 had no route at all. It was never the SETTING that was missing: a tracked post is a tracked_profiles row like any other, its webhook fires through the same path, its leads are scored against the same ICP rules, and POST /api/v1/post/track has always returned a `syncId` from a real sync job. Only the addressing was. PARTIAL UPDATE: only the fields present in the body change, so a flag can be flipped without restating the URL; a body with none of them is a 400 rather than a silent no-op. EXPLICIT `false` IS A VALUE \u2014 `autoSend: false` puts the source into deliver-only-on-explicit-push mode, which is what POST /api/v1/{profile|company}/{username}/push, /api/v1/keyword/{id}/push and, for any kind (the only push a tracked post has), /api/v1/sources/{id}/push then act on. `webhookUrl` of `\"\"` or `null` CLEARS delivery, and turns `syncEvents` off with it because there is nowhere left to deliver \u2014 asking for `syncEvents: true` with no URL is a 400 instead. The URL passes the same guard the delivery path applies, so a URL that saves is always one that can fire. FOR A TRACKED POST, `webhookUrl`, `icpOnly` and `autoSend` apply to its `lead.detected` exactly as for any source (`autoSend: false` holds its leads for POST /api/v1/sources/{id}/push), while `syncEvents: true` is a 400 with `code: \"not_supported_for_post\"`: no sync lifecycle event is ever sent for a post. SAVING A URL DOES NOT DELIVER HISTORY; the push endpoints do that. UNKNOWN FIELDS ARE REFUSED: a property not listed here is a 400 carrying `code: \"unknown_field\"` and naming the offending field, rather than a 200 that silently dropped it.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The SOURCE id, as GET /api/v1/sources returns it in each source's `id`. Every kind of source has one \u2014 that is the whole point of this family: a person and a company page are also reachable by handle, a keyword search's handle is its free-text search terms, and a tracked post's is an activity URN, so the id is the only identifier all four share."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "description": "Partial update \u2014 only the fields present change.",
                "properties": {
                  "webhookUrl": {
                    "type": "string",
                    "description": "Public https URL to POST leads to. Empty string or null clears it (localhost/private hosts are rejected)."
                  },
                  "icpOnly": {
                    "type": "boolean",
                    "description": "Deliver only leads whose isIcp is true. Applies to every kind of source, a tracked post included."
                  },
                  "autoSend": {
                    "type": "boolean",
                    "description": "Queue FUTURE leads automatically (default true). `false` = deliver only on an explicit push, which for any kind of source, a tracked post included, is POST /api/v1/sources/{id}/push."
                  },
                  "syncEvents": {
                    "type": "boolean",
                    "description": "Send sync.completed / sync.failed. Requires a webhookUrl to deliver to. Not available for a TRACKED POST: no sync lifecycle event is ever sent for a post, so `true` on one is a 400 with `code: \"not_supported_for_post\"`."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated webhook configuration, read back from the database.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "sourceId",
                    "type",
                    "username",
                    "webhook"
                  ],
                  "properties": {
                    "sourceId": {
                      "type": "string",
                      "format": "uuid",
                      "description": "The source id you addressed \u2014 the same value GET /api/v1/sources reports as `id`."
                    },
                    "type": {
                      "type": "string",
                      "enum": [
                        "person",
                        "company",
                        "post",
                        "keyword"
                      ],
                      "description": "The kind of source this id turned out to be, spelled exactly as GET /api/v1/sources spells it. You do not have to know the kind to call these routes; this is how you learn it."
                    },
                    "username": {
                      "type": "string",
                      "description": "The row's stored label, which means something different per kind: a LinkedIn handle for a person or company page, the activity URN for a tracked post, and the joined search terms for a keyword search. Reported, never used to address these routes."
                    },
                    "webhook": {
                      "type": "object",
                      "properties": {
                        "webhookUrl": {
                          "type": "string",
                          "nullable": true,
                          "description": "Where leads are POSTed. Null = no webhook."
                        },
                        "icpOnly": {
                          "type": "boolean",
                          "description": "Only deliver leads matching the source's ICP filter."
                        },
                        "autoSend": {
                          "type": "boolean",
                          "description": "Auto-deliver new leads (true) or deliver only on explicit push (false)."
                        },
                        "syncEvents": {
                          "type": "boolean",
                          "description": "Also send signed sync.completed/sync.failed lifecycle callbacks."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The path segment is not a source id (a UUID from GET /api/v1/sources), or the body is invalid \u2014 including `syncEvents: true` on a TRACKED POST, `code: \"not_supported_for_post\"`. A malformed id is deliberately a 400 rather than a 404: \"that is not an id\" and \"you do not have that source\" are different answers."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "No source with that id on this team \u2014 including one you UNTRACKED. Untracking is a soft delete, and a deactivated person, company or post is not readable and not actionable: its retained leads are out of GET /api/v1/leads too, and its webhook config is what a push would deliver to. A STOPPED KEYWORD SEARCH is the deliberate exception \u2014 its leads are kept and served, so it stays readable and editable here. The 404 is identical for an id that belongs to another team, so this is not an existence oracle."
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/sources/{id}/icp": {
      "get": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Get ICP criteria for any tracked source, by source id",
        "operationId": "getSourceIcp",
        "description": "Read the ICP (Ideal Customer Profile) criteria \u2014 filter rules and match mode \u2014 that decide which of this source's leads are `isIcp`, and with a webhook's `icpOnly`, which are delivered. ADDRESSED BY SOURCE ID, AND IT WORKS FOR EVERY KIND OF SOURCE \u2014 a person, a company page, a TRACKED POST or a KEYWORD SEARCH. The per-kind routes are keyed by whatever identifier that kind happens to have (/api/v1/{profile|company}/{username}/... by LinkedIn handle, /api/v1/keyword/{id}/... by source id), which is why a tracked post \u2014 whose identifier is an activity URN \u2014 had no route at all. It was never the SETTING that was missing: a tracked post is a tracked_profiles row like any other, its webhook fires through the same path, its leads are scored against the same ICP rules, and POST /api/v1/post/track has always returned a `syncId` from a real sync job. Only the addressing was. The scoring has always been kind-blind: capture evaluates these rules off the source row without regard for what kind of source it is. `rules: []` means no filter at all, which is not the same as a filter that matches nothing.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The SOURCE id, as GET /api/v1/sources returns it in each source's `id`. Every kind of source has one \u2014 that is the whole point of this family: a person and a company page are also reachable by handle, a keyword search's handle is its free-text search terms, and a tracked post's is an activity URN, so the id is the only identifier all four share."
          }
        ],
        "responses": {
          "200": {
            "description": "The source's current ICP configuration.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "sourceId",
                    "type",
                    "username",
                    "icp"
                  ],
                  "properties": {
                    "sourceId": {
                      "type": "string",
                      "format": "uuid",
                      "description": "The source id you addressed \u2014 the same value GET /api/v1/sources reports as `id`."
                    },
                    "type": {
                      "type": "string",
                      "enum": [
                        "person",
                        "company",
                        "post",
                        "keyword"
                      ],
                      "description": "The kind of source this id turned out to be, spelled exactly as GET /api/v1/sources spells it. You do not have to know the kind to call these routes; this is how you learn it."
                    },
                    "username": {
                      "type": "string",
                      "description": "The row's stored label, which means something different per kind: a LinkedIn handle for a person or company page, the activity URN for a tracked post, and the joined search terms for a keyword search. Reported, never used to address these routes."
                    },
                    "icp": {
                      "type": "object",
                      "required": [
                        "matchMode",
                        "rules"
                      ],
                      "properties": {
                        "matchMode": {
                          "type": "string",
                          "enum": [
                            "all",
                            "any"
                          ]
                        },
                        "rules": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "required": [
                              "column",
                              "operator",
                              "value"
                            ],
                            "properties": {
                              "column": {
                                "type": "string"
                              },
                              "operator": {
                                "type": "string"
                              },
                              "value": {
                                "type": "string"
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The path segment is not a source id (a UUID from GET /api/v1/sources), or the body is invalid. A malformed id is deliberately a 400 rather than a 404: \"that is not an id\" and \"you do not have that source\" are different answers."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "No source with that id on this team \u2014 including one you UNTRACKED. Untracking is a soft delete, and a deactivated person, company or post is not readable and not actionable: its retained leads are out of GET /api/v1/leads too, and its webhook config is what a push would deliver to. A STOPPED KEYWORD SEARCH is the deliberate exception \u2014 its leads are kept and served, so it stays readable and editable here. The 404 is identical for an id that belongs to another team, so this is not an existence oracle."
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "put": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Set ICP criteria for any tracked source, by source id",
        "operationId": "setSourceIcp",
        "description": "Set the ICP criteria for any tracked source (rules and/or match mode). ADDRESSED BY SOURCE ID, AND IT WORKS FOR EVERY KIND OF SOURCE \u2014 a person, a company page, a TRACKED POST or a KEYWORD SEARCH. The per-kind routes are keyed by whatever identifier that kind happens to have (/api/v1/{profile|company}/{username}/... by LinkedIn handle, /api/v1/keyword/{id}/... by source id), which is why a tracked post \u2014 whose identifier is an activity URN \u2014 had no route at all. It was never the SETTING that was missing: a tracked post is a tracked_profiles row like any other, its webhook fires through the same path, its leads are scored against the same ICP rules, and POST /api/v1/post/track has always returned a `syncId` from a real sync job. Only the addressing was. Partial update: pass `rules` and/or `matchMode`; a body with neither is a 400. `rules: []` or `null` clears the filter, which makes every lead ICP again \u2014 a widening, not a narrowing. EXISTING LEADS ARE RE-SCORED IMMEDIATELY, the same retroactive pass the dashboard runs, so `isIcp` filters and `icpOnly` delivery reflect the change now rather than at the next sync. `dryRun: true` ANSWERS \u201cWHAT WOULD THIS SELECT?\u201d WITHOUT APPLYING IT: validate, evaluate against this source's existing leads, and answer 200 with `counts` \u2014 `{ matching, notMatching }` over every lead the source has captured \u2014 a `sample` of up to ten matching leads by `id` and `name`, the `icp` (`matchMode` and `rules`) that was evaluated, and `dryRun: true`. Nothing is written and nothing fires. Use it before any rule change on a source whose webhook delivers `icpOnly` or whose pushes use `scope: \"icp\"`, because saving re-scores every existing lead immediately. UNKNOWN FIELDS ARE REFUSED: a property not listed here is a 400 carrying `code: \"unknown_field\"` and naming the offending field, rather than a 200 that silently dropped it.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The SOURCE id, as GET /api/v1/sources returns it in each source's `id`. Every kind of source has one \u2014 that is the whole point of this family: a person and a company page are also reachable by handle, a keyword search's handle is its free-text search terms, and a tracked post's is an activity URN, so the id is the only identifier all four share."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "description": "Partial update \u2014 pass rules and/or matchMode (at least one), or `dryRun: true` alone to evaluate the configuration already stored. No other property is accepted.",
                "additionalProperties": false,
                "properties": {
                  "dryRun": {
                    "type": "boolean",
                    "default": false,
                    "description": "Evaluate this rule set against the source's ALREADY-CAPTURED leads and CHANGE NOTHING: no rules are written, no lead is re-scored, and no webhook or push fires. Same meaning as `dryRun` on the push endpoints. The response carries `dryRun: true` plus `counts` and a `sample`, so a caller can assert on that flag before trusting that a live source was left alone. THE CONFIG EVALUATED IS THE EFFECTIVE ONE \u2014 the fields in this body over the ones already stored, which is what saving would leave behind \u2014 so `{\"matchMode\":\"any\",\"dryRun\":true}` scores the STORED rules under the new mode, and `{\"dryRun\":true}` alone evaluates the configuration as it stands. Validation runs first, so a rule set this endpoint would refuse is refused here too rather than previewed."
                  },
                  "matchMode": {
                    "type": "string",
                    "enum": [
                      "all",
                      "any"
                    ]
                  },
                  "rules": {
                    "type": "array",
                    "nullable": true,
                    "description": "The ICP filter rules. [] or null clears the filter.",
                    "items": {
                      "type": "object",
                      "required": [
                        "column",
                        "operator",
                        "value"
                      ],
                      "properties": {
                        "column": {
                          "type": "string"
                        },
                        "operator": {
                          "type": "string"
                        },
                        "value": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated ICP configuration, with existing leads already re-scored.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "sourceId",
                    "type",
                    "username",
                    "icp"
                  ],
                  "properties": {
                    "dryRun": {
                      "type": "boolean",
                      "enum": [
                        true
                      ],
                      "description": "PRESENT, AND ALWAYS `true`, ONLY ON A DRY RUN \u2014 absent on a PUT that actually wrote. Its presence is the assertion that nothing was changed and nothing fired; `icp` then reports the configuration that WOULD have been saved rather than the one that was."
                    },
                    "counts": {
                      "type": "object",
                      "description": "Dry runs only. How this source's already-captured leads split under the evaluated configuration. The two add up to every lead the source has captured, and they are produced by the SAME pass that re-scores `isIcp` on a real save \u2014 so the preview cannot disagree with what saving does.",
                      "required": [
                        "matching",
                        "notMatching"
                      ],
                      "properties": {
                        "matching": {
                          "type": "integer",
                          "description": "Leads that WOULD be marked `isIcp: true`."
                        },
                        "notMatching": {
                          "type": "integer",
                          "description": "Leads that would NOT \u2014 the cohort an `icpOnly` webhook would stop delivering and a `scope: \"icp\"` push would stop selecting."
                        }
                      }
                    },
                    "sample": {
                      "type": "array",
                      "description": "Dry runs only. Up to ten of the matching leads, so the counts can be checked against real people. A SAMPLE, not a page: no cursor and no ordering guarantee \u2014 use GET /api/v1/leads?isIcp=true after saving for the full set.",
                      "items": {
                        "type": "object",
                        "required": [
                          "id",
                          "name"
                        ],
                        "properties": {
                          "id": {
                            "type": "string",
                            "format": "uuid",
                            "description": "The lead id, exactly as GET /api/v1/leads reports it."
                          },
                          "name": {
                            "type": "string",
                            "nullable": true,
                            "description": "The lead's name, or null when enrichment has not resolved one."
                          }
                        }
                      }
                    },
                    "sourceId": {
                      "type": "string",
                      "format": "uuid",
                      "description": "The source id you addressed \u2014 the same value GET /api/v1/sources reports as `id`."
                    },
                    "type": {
                      "type": "string",
                      "enum": [
                        "person",
                        "company",
                        "post",
                        "keyword"
                      ],
                      "description": "The kind of source this id turned out to be, spelled exactly as GET /api/v1/sources spells it. You do not have to know the kind to call these routes; this is how you learn it."
                    },
                    "username": {
                      "type": "string",
                      "description": "The row's stored label, which means something different per kind: a LinkedIn handle for a person or company page, the activity URN for a tracked post, and the joined search terms for a keyword search. Reported, never used to address these routes."
                    },
                    "icp": {
                      "type": "object",
                      "required": [
                        "matchMode",
                        "rules"
                      ],
                      "properties": {
                        "matchMode": {
                          "type": "string",
                          "enum": [
                            "all",
                            "any"
                          ]
                        },
                        "rules": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "required": [
                              "column",
                              "operator",
                              "value"
                            ],
                            "properties": {
                              "column": {
                                "type": "string"
                              },
                              "operator": {
                                "type": "string"
                              },
                              "value": {
                                "type": "string"
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The path segment is not a source id (a UUID from GET /api/v1/sources), or the body is invalid. A malformed id is deliberately a 400 rather than a 404: \"that is not an id\" and \"you do not have that source\" are different answers."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "No source with that id on this team \u2014 including one you UNTRACKED. Untracking is a soft delete, and a deactivated person, company or post is not readable and not actionable: its retained leads are out of GET /api/v1/leads too, and its webhook config is what a push would deliver to. A STOPPED KEYWORD SEARCH is the deliberate exception \u2014 its leads are kept and served, so it stays readable and editable here. The 404 is identical for an id that belongs to another team, so this is not an existence oracle."
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/leads": {
      "get": {
        "tags": [
          "Leads"
        ],
        "summary": "List captured leads",
        "operationId": "listLeads",
        "description": "List and filter the engagers already captured and enriched for your tracked profiles \u2014 no re-sweep of posts required. Read-only and synchronous, so it draws on the higher read rate limit. Returns enriched leads only, and (unless you scope to one tracked source with profileId/username \u2014 the source that captured the leads, not a lead's own profile) only profiles whose first sync has finished \u2014 the same set the dashboard and CSV export show. Ordered newest-engagement-first. Page with limit/offset and read `total`/`hasMore`; for incremental pulls, filter on `since`/`until` (both compare against the lead's createdAt). Unknown query parameters are rejected with a 400, so a typo or a removed filter never silently returns an unfiltered page. GRAIN: this endpoint returns one row per ENGAGEMENT (each like or comment is its own row), so a repeat engager appears once per interaction and `total` counts ENGAGEMENTS, NOT unique people. Use GET /api/v1/engagers (one row per PERSON, with engagementCount) when you need a count of people.\n\nWHAT A PENDING LEAD IS. Capture and enrichment are two steps: capture records the engager (name, LinkedIn username and URL, avatar), enrichment then adds job title, company and country. This endpoint returns ENRICHED leads only, so a captured-but-unenriched lead is absent from `data` and `total` entirely \u2014 it is not returned with blank fields. `pendingEnrichment` counts exactly those absent leads. Enrichment is GATED: it only runs for a team whose subscription is active (or a live trial) and which has enriching credits. A team that is cancelled, blocked or out of credits accumulates pending leads INDEFINITELY \u2014 measured in production, one cancelled team holds 54,947 leads that have never been attempted, the oldest waiting 51 days. So a large pendingEnrichment means enrichment is gated for that team, NOT that the queue is stuck. Check GET /api/v1/credits.\n\nSCOPING TO A KIND OF SOURCE. `?sourceKind=keyword|post|profile` narrows the all-sources view to the leads captured by one kind of source \u2014 the way to answer \"how many leads have my keyword searches produced\" in a single call. It spans untracked keyword searches, whose leads are deliberately kept, which is why it can return MORE than the sum over GET /api/v1/sources (that lists only active sources).\n\nUNTRACKED SOURCES. A source you untracked is out of scope here on BOTH paths: absent from the all-sources view, and a 404 when named with profileId/username \u2014 the same rule the dashboard applies, so this endpoint cannot answer with leads the product told you were gone. KEYWORD searches are the deliberate exception: untracking one keeps its leads, and they stay readable both by id and under `sourceKind=keyword`.\n\nCOMPANY FIELDS: companyName (also `company`), companyUrl (company website URL), companyDomain (website hostname), companyLinkedinUrl (LinkedIn company page), companyDescription, companyIndustry, companyLocation (headquarters), companyEmployeeCount, companyStaffRange and companyEnrichedAt describe the current employer. companyUrl is the company's own website, from the website field of its company record, and never a LinkedIn URL; companyLinkedinUrl is its LinkedIn company page. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits. companyStaffRange is the LinkedIn size bucket and companyEmployeeCount is the reported total, so the two can disagree. companyEnrichedAt is null until the company has been resolved; after that, a null company field means the company record has no value for it. Resolving a company costs one provider lookup, not a Cornersight credit, and each company record is cached for 6 months. companyDescription and companyLocation (headquarters) come from the company record, resolved once per company and cached for 6 months, at no enriching-credit cost.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Page size, 1-100 (default 50). Outside the range is a 400, not a clamp.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 50
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Number of leads to skip. 0 or more; a negative offset is a 400. An offset past the last row is an empty page, not an error.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          },
          {
            "name": "profileId",
            "in": "query",
            "required": false,
            "description": "Scope to one tracked SOURCE by its Cornersight id. This is the person or company page you MONITOR that captured these leads (the id from GET /api/v1/sources, or returned when you track it) \u2014 NOT the lead's own LinkedIn profile. 404 if it is not one of your tracked sources. Mutually exclusive with username.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "username",
            "in": "query",
            "required": false,
            "description": "Scope to one tracked SOURCE by its LinkedIn username \u2014 the person or company you monitor, not the lead's username. 404 if not tracked. Mutually exclusive with profileId.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "engagementType",
            "in": "query",
            "required": false,
            "description": "Filter by engagement kind (case-insensitive): Like, Comment, or Author (a keyword search's post author, captured when the search has capturePostAuthors on).",
            "schema": {
              "type": "string",
              "enum": [
                "Like",
                "Comment",
                "Author"
              ]
            }
          },
          {
            "name": "isIcp",
            "in": "query",
            "required": false,
            "description": "Only leads matching (true) or not matching (false) the profile's ICP rules.",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "webhookStatus",
            "in": "query",
            "required": false,
            "description": "Filter by webhook delivery state.",
            "schema": {
              "type": "string",
              "enum": [
                "pending",
                "sent",
                "failed",
                "no_webhook"
              ]
            }
          },
          {
            "name": "includeInactive",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean",
              "default": false
            },
            "description": "When true, sources the team has UNTRACKED are back in scope. Untracking is a SOFT DELETE on every kind \u2014 the row is deactivated and every lead it captured is kept, because deleting cascades and is unrecoverable \u2014 and until this flag existed a person, company page or tracked post's kept leads could not be read back on any parameter: absent from the all-sources view and a 404 when named. This is the leads half of GET /api/v1/sources?includeInactive=true, spelled the same way and opt-in for the same reason. DEFAULT FALSE, and the default is unchanged: a caller who does not ask sees exactly what they saw before, which is what the dashboard's untrack dialog promises. It relaxes BOTH paths \u2014 the all-sources view and profileId/username, which would otherwise 404 on an id this flag had just widened the view to include. A keyword search's leads are readable either way; that exception is the KIND's, not this flag's. \u26a0\ufe0f READING IS NOT ACTING: an untracked source still cannot be pushed, and its webhook and ICP configuration still answer 404 \u2014 delivering a deleted source's leads to a webhook is the failure that rule exists for. Rejected with a 400 unless it is exactly true or false."
          },
          {
            "name": "includeSyncing",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean",
              "default": false
            },
            "description": "When true, the all-profiles view (no profileId/username) also includes profiles currently re-syncing, whose already-enriched leads are otherwise hidden until the sync finishes. leads_ready is flipped false at the start of every sync (including the daily one). Ignored when profileId or username is set."
          },
          {
            "name": "sourceKind",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "all",
                "profile",
                "post",
                "keyword"
              ],
              "default": "all"
            },
            "description": "Scope the all-sources view to one KIND of capturing source. A lead has no kind of its own \u2014 it inherits the tracked source that captured it \u2014 so this narrows which SOURCES are in scope, not which leads. `keyword` is keyword searches, `post` is tracked posts, `profile` is person AND company pages (both are one kind here), and `all` is the default, which adds no narrowing at all.\n\nTHIS IS HOW YOU COUNT ONE KIND'S LEADS: `?sourceKind=keyword&limit=1`, then read `total`. Summing per-source counts from GET /api/v1/sources cannot answer it, because that lists only ACTIVE sources while an untracked keyword search keeps its leads and stays in this scope. On the team that reported this the two came to 402 and 96.\n\nThe vocabulary is deliberately not this API's `type`: `person` and `company` are rejected with a 400 naming `profile`, rather than accepted as aliases that would silently widen to every profile source. Cannot be combined with profileId/trackedProfile/source/username \u2014 a named source is already one kind, and a filter that would not be applied is a 400 here rather than a page that does not mean what was asked."
          },
          {
            "name": "since",
            "in": "query",
            "required": false,
            "description": "Only leads stored at/after this ISO 8601 timestamp.",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "until",
            "in": "query",
            "required": false,
            "description": "Only leads stored at/before this ISO 8601 timestamp.",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "name",
            "in": "query",
            "required": false,
            "description": "Case-insensitive substring match on the lead's name.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "jobTitle",
            "in": "query",
            "required": false,
            "description": "Case-insensitive substring match on job title.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "company",
            "in": "query",
            "required": false,
            "description": "Case-insensitive substring match on company name.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "companyDomain",
            "in": "query",
            "required": false,
            "description": "Case-insensitive substring match on company domain.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "companyIndustry",
            "in": "query",
            "required": false,
            "description": "Case-insensitive substring match on the cached employer industry. Unknown industries do not match; retrieving the industry costs the customer no enriching credits.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "country",
            "in": "query",
            "required": false,
            "description": "Case-insensitive substring match on country.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of captured leads.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LeadList"
                },
                "examples": {
                  "page": {
                    "value": {
                      "data": [
                        {
                          "id": "6f1b6f2e-1f4a-4c1d-9f2b-2a7c1d3e4f50",
                          "profileId": "b2c3d4e5-6789-4abc-9def-0123456789ab",
                          "name": "Dana Reyes",
                          "linkedinUsername": "danareyes",
                          "linkedinUrl": "https://www.linkedin.com/in/danareyes/",
                          "avatarUrl": null,
                          "jobTitle": "VP Marketing",
                          "company": "Northwind",
                          "companyDomain": "northwind.com",
                          "country": "United States",
                          "engagementType": "Comment",
                          "commentText": "This matches what we're seeing.",
                          "commentPostedAt": null,
                          "isIcp": true,
                          "webhookStatus": "sent",
                          "postUrl": "https://www.linkedin.com/feed/update/urn:li:activity:7300000000000000000/",
                          "postPostedAt": "2026-07-18T09:12:00Z",
                          "detectedAt": "2026-07-18T10:02:11Z",
                          "createdAt": "2026-07-18T10:02:11Z"
                        }
                      ],
                      "total": 1284,
                      "limit": 50,
                      "offset": 0,
                      "hasMore": true
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "A query parameter was malformed (bad limit/offset/timestamp, unknown enum value, or both profileId and username).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "404": {
            "description": "The requested profileId/username is not a tracked source for this team \u2014 which now includes a source you UNTRACKED, since its leads stop being served. Keyword searches are the exception: an untracked search's id still resolves, because its leads are kept.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/leads/raw": {
      "get": {
        "tags": [
          "Leads"
        ],
        "summary": "List raw (unenriched) leads",
        "operationId": "listRawLeads",
        "description": "List the RAW leads of sources in raw mode (`enrichLeads: false`): people captured from engagement exactly as capture recorded them and NEVER enriched. Read-only and synchronous. A raw lead carries who engaged (linkedinUrl, linkedinUrn, name), how (action and commentText), on which post (post.url, post.urn, post.postedAt) and when it was captured (detectedAt) \u2014 and no job title, company, country or ICP score, because no enrichment provider is ever called for it.\n\nWHERE RAW LEADS COME FROM, AND WHAT THEY COST. `enrichLeads: false` on POST /api/v1/enrich/{profile,company} (with saveTrackedProfile: true), POST /api/v1/post/track, POST /api/v1/keyword/track, PATCH /api/v1/{profile,company}/{username} or PATCH /api/v1/keyword/{id} puts a source in raw mode; GET /api/v1/sources reports it per source as `enrichLeads`. The price is unchanged: ONE CREDIT PER NEW PERSON PER SOURCE, charged when the lead is captured, with repeats free and the same ledger and caps as enrichment. Reading them here charges nothing. Switching the mode needs no `confirmSpend` and applies to leads captured or processed after the switch \u2014 leads already enriched stay enriched, raw leads stay raw.\n\nONLY HERE. A raw lead is `enriched = false`, so it never appears in GET /api/v1/leads, GET /api/v1/engagers, the dashboard, CSV exports, webhooks, lead pushes or integrations. This endpoint is how raw leads are delivered.\n\nSCOPE AND VISIBILITY ARE GET /api/v1/leads'. With no source named it spans every source the team tracks; a source you UNTRACKED is out of scope (and a 404 when named) unless `includeInactive=true`, except a keyword search, whose leads are kept either way. One source selector at most: `profileId` (also accepted as `trackedProfile` or `source`) or `username`; an unknown source is a 404. There is no readiness gate and no `includeSyncing`: a raw lead is final the moment it is written, so a source's raw leads stay listed while it re-syncs.\n\nORDER AND PAGING. Newest `detectedAt` first, `id` as the tiebreak, so deep offsets are deterministic. `since`/`until` compare against `detectedAt`, so an incremental pull is `since=<the newest detectedAt you already hold>`. Page with limit/offset and follow `hasMore`; `total` is the exact size of the filtered set. Unknown query parameters are a 400, so a typo never returns an unfiltered page.\n\nNAMES. Capture stores a person's handle or member id where the provider sent no display name. A raw lead is never enriched, so that fallback would reach you as the name; instead `name` is null whenever the stored value is only an identifier (it matches the handle or the URN, or looks like one: ACoAA\u2026 or urn:li:\u2026). An id is never returned as a name \u2014 identify the person by `linkedinUrl` / `linkedinUrn`.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Page size, 1-100 (default 50). Outside the range is a 400, not a clamp.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 50
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Number of raw leads to skip. 0 or more; a negative offset is a 400. An offset past the last row is an empty page, not an error.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          },
          {
            "name": "profileId",
            "in": "query",
            "required": false,
            "description": "Scope to one tracked SOURCE by its Cornersight id \u2014 the person, company page, post or keyword search you track that captured these leads (the `id` from GET /api/v1/sources), NOT the lead's own profile. Also accepted as `trackedProfile` or `source`. 404 if it is not one of your tracked sources. Mutually exclusive with username.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "username",
            "in": "query",
            "required": false,
            "description": "Scope to one tracked SOURCE by its LinkedIn username \u2014 the person or company you monitor, not the lead's username. 404 if not tracked. Mutually exclusive with profileId.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "action",
            "in": "query",
            "required": false,
            "description": "Only this kind of engagement (case-insensitive): Like, Comment, or Author (a keyword search's post author, captured when the search has capturePostAuthors on).",
            "schema": {
              "type": "string",
              "enum": [
                "Like",
                "Comment",
                "Author"
              ]
            }
          },
          {
            "name": "since",
            "in": "query",
            "required": false,
            "description": "Only raw leads detected at/after this ISO 8601 timestamp (compared against detectedAt).",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "until",
            "in": "query",
            "required": false,
            "description": "Only raw leads detected at/before this ISO 8601 timestamp (compared against detectedAt).",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "includeInactive",
            "in": "query",
            "required": false,
            "description": "When true, sources the team has UNTRACKED are back in scope, on both the all-sources view and a named profileId/username \u2014 the same opt-in, with the same meaning, as on GET /api/v1/leads. A keyword search's leads are readable either way. Rejected with a 400 unless it is exactly true or false.",
            "schema": {
              "type": "boolean",
              "default": false
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of raw leads.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RawLeadList"
                },
                "examples": {
                  "page": {
                    "value": {
                      "data": [
                        {
                          "id": "6f1b6f2e-1f4a-4c1d-9f2b-2a7c1d3e4f51",
                          "sourceId": "b2c3d4e5-6789-4abc-9def-0123456789ab",
                          "linkedinUrl": "https://www.linkedin.com/in/jane-doe",
                          "linkedinUrn": "ACoAAB1a2b3c4d5e6f7g8h9i0j",
                          "name": "Jane Doe",
                          "action": "Comment",
                          "commentText": "This matches what we're seeing.",
                          "post": {
                            "url": "https://www.linkedin.com/feed/update/urn:li:activity:7300000000000000000/",
                            "urn": "urn:li:activity:7300000000000000000",
                            "postedAt": "2026-07-17T08:00:00.000Z"
                          },
                          "detectedAt": "2026-07-18T10:02:11.000Z"
                        }
                      ],
                      "total": 1284,
                      "limit": 50,
                      "offset": 0,
                      "hasMore": true
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "A query parameter was malformed or unknown (bad limit/offset/timestamp/action/includeInactive, more than one source selector, or a profileId that is not a UUID).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/SubscriptionInactive"
          },
          "404": {
            "description": "The requested profileId/username is not a tracked source for this team \u2014 including one you untracked, unless includeInactive=true. Keyword searches are the exception: an untracked search's id still resolves.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/engagers": {
      "get": {
        "tags": [
          "Leads"
        ],
        "summary": "List top engagers",
        "operationId": "listEngagers",
        "description": "Aggregate captured leads to one row per PERSON \u2014 the repeat-engagement signal GET /api/v1/leads (one row per engagement) can't answer directly. Sorted by engagementCount desc by default; pass orderBy=lastEngagedAt for most-recent-first, and minEngagements to keep only repeat engagers. Scope and gating mirror GET /api/v1/leads exactly (team-scoped, enriched only, leads_ready sources unless includeSyncing=true). Read-only, returns immediately. Paginated: { data, total, limit, offset, hasMore }.\n\nIDENTITY: rows are one per PERSON even when the same person was captured under two LinkedIn spellings. ~3% of leads store a member URN (\"ACoAAB...\") in linkedinUsername instead of a public handle; aggregation groups on the captured member URN where there is one, so a handle capture and a URN capture of the same person merge into a single row with the combined engagementCount, reported under the handle. linkedinUsername can still be a URN for someone only ever seen that way.\n\nUNTRACKED SOURCES. A source you untracked is out of scope here on BOTH paths \u2014 absent from the all-sources view, and a 404 when named with profileId/username \u2014 the same rule GET /api/v1/leads applies, because these are the same leads grouped. \"Scope and gating mirror GET /api/v1/leads exactly\" was true of readiness and team scope and NOT of this rule: this endpoint resolved its own source set with no status clause, so a deleted profile kept answering with its engagers while /leads 404'd on the same id. KEYWORD searches are the deliberate exception on both endpoints: untracking one keeps its leads, so its engagers stay readable, by id and in the all-sources view. Untracking never erases anything \u2014 the source is deactivated and its rows are retained; what deleting changes is what is SERVED. Use GET /api/v1/sources?includeInactive=true to enumerate what you used to track.\n\nCOMPANY FIELDS: companyName (also `company`), companyUrl (company website URL), companyDomain (website hostname), companyLinkedinUrl (LinkedIn company page), companyDescription, companyIndustry, companyLocation (headquarters), companyEmployeeCount, companyStaffRange and companyEnrichedAt describe the current employer. companyUrl is the company's own website, from the website field of its company record, and never a LinkedIn URL; companyLinkedinUrl is its LinkedIn company page. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits. companyStaffRange is the LinkedIn size bucket and companyEmployeeCount is the reported total, so the two can disagree. companyEnrichedAt is null until the company has been resolved; after that, a null company field means the company record has no value for it. Resolving a company costs one provider lookup, not a Cornersight credit, and each company record is cached for 6 months. companyDescription and companyLocation (headquarters) come from the company record, resolved once per company and cached for 6 months, at no enriching-credit cost.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 50
            },
            "description": "Page size, 1-100 (default 50). Outside the range is a 400, not a clamp."
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            },
            "description": "Engagers to skip for paging (default 0). 0 or more; a negative offset is a 400. An offset at or past the last row is an empty page, not an error, and it still carries the collection's `total` \u2014 walking one page too far is ordinary paging and must not read as \"this collection is empty\"."
          },
          {
            "name": "minEngagements",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 1
            },
            "description": "Only engagers with at least this many total engagements (default 1). Use 2+ for repeat engagers; 0 means no minimum. A negative value is a 400."
          },
          {
            "name": "orderBy",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "engagementCount",
                "lastEngagedAt"
              ],
              "default": "engagementCount"
            },
            "description": "Sort key."
          },
          {
            "name": "profileId",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Scope to one tracked source id (aliases: trackedProfile, source). Mutually exclusive with username."
          },
          {
            "name": "username",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Scope to one tracked source by LinkedIn username. Mutually exclusive with profileId."
          },
          {
            "name": "engagementType",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "Like",
                "Comment",
                "Author"
              ]
            },
            "description": "Count only this engagement kind: Like, Comment, or Author (a keyword search's post author). Author rows count toward engagementCount but not likeCount or commentCount."
          },
          {
            "name": "isIcp",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean"
            },
            "description": "Only ICP-matching engagers."
          },
          {
            "name": "since",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Only engagements at/after this ISO 8601 timestamp."
          },
          {
            "name": "until",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Only engagements at/before this ISO 8601 timestamp."
          },
          {
            "name": "includeInactive",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean",
              "default": false
            },
            "description": "When true, sources the team has UNTRACKED are back in scope. Untracking is a SOFT DELETE on every kind \u2014 the row is deactivated and every lead it captured is kept, because deleting cascades and is unrecoverable \u2014 and until this flag existed a person, company page or tracked post's kept leads could not be read back on any parameter: absent from the all-sources view and a 404 when named. This is the leads half of GET /api/v1/sources?includeInactive=true, spelled the same way and opt-in for the same reason. DEFAULT FALSE, and the default is unchanged: a caller who does not ask sees exactly what they saw before, which is what the dashboard's untrack dialog promises. It relaxes BOTH paths \u2014 the all-sources view and profileId/username, which would otherwise 404 on an id this flag had just widened the view to include. A keyword search's leads are readable either way; that exception is the KIND's, not this flag's. \u26a0\ufe0f READING IS NOT ACTING: an untracked source still cannot be pushed, and its webhook and ICP configuration still answer 404 \u2014 delivering a deleted source's leads to a webhook is the failure that rule exists for. Rejected with a 400 unless it is exactly true or false."
          },
          {
            "name": "includeSyncing",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean",
              "default": false
            },
            "description": "Include sources currently re-syncing (default false). Ignored when profileId/username is set."
          }
        ],
        "responses": {
          "200": {
            "description": "A page of engagers, highest-intent first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "linkedinUsername": {
                            "type": "string",
                            "description": "The engager's LinkedIn handle, as stored on the lead rows behind this aggregate. THIS PAYLOAD IS NOT THE SAME SHAPE AS A LEAD: it is not passed through the identity presenter that GET /api/v1/leads uses, and it carries no `linkedinUrn` field. So for a person whose stored identity key is a member URN \u2014 LinkedIn served no public handle and enrichment never resolved one \u2014 THIS FIELD IS THAT URN (`ACoAAB1_DY0B-SeJU30N`), and `linkedinUrl` is built from it, so it does not resolve as a public profile. Test for the `ACo` prefix before using the value as a vanity handle: CRM matching, dedup and display all silently miss on it. The same person on GET /api/v1/leads is presented with the two kinds of identity separated \u2014 `linkedinUsername: null` and the URN in `linkedinUrn` \u2014 so read leads rather than engagers when you need to tell a handle from an identifier."
                          },
                          "name": {
                            "type": "string",
                            "nullable": true
                          },
                          "linkedinUrl": {
                            "type": "string",
                            "nullable": true
                          },
                          "avatarUrl": {
                            "type": "string",
                            "nullable": true
                          },
                          "jobTitle": {
                            "type": "string",
                            "nullable": true
                          },
                          "company": {
                            "type": "string",
                            "nullable": true
                          },
                          "companyName": {
                            "type": "string",
                            "nullable": true,
                            "description": "Company name; same value as company."
                          },
                          "companyDomain": {
                            "type": "string",
                            "nullable": true,
                            "description": "Company website hostname, without a scheme or path."
                          },
                          "companyUrl": {
                            "type": "string",
                            "nullable": true,
                            "description": "companyUrl is the company's own website, from the website field of its company record, and never a LinkedIn URL; companyLinkedinUrl is its LinkedIn company page. Null when the company record has no website. companyDomain is the same website's hostname. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits."
                          },
                          "companyDescription": {
                            "type": "string",
                            "nullable": true,
                            "description": "Company description (its tagline when it has no description). companyDescription and companyLocation (headquarters) come from the company record, resolved once per company and cached for 6 months, at no enriching-credit cost."
                          },
                          "companyLocation": {
                            "type": "string",
                            "nullable": true,
                            "description": "Company headquarters location, as City, Region, Country. companyDescription and companyLocation (headquarters) come from the company record, resolved once per company and cached for 6 months, at no enriching-credit cost."
                          },
                          "companyLinkedinUrl": {
                            "type": "string",
                            "nullable": true,
                            "description": "The employer's LinkedIn company page, read from the person's current position (the company record fills it only when that is missing), or null. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits."
                          },
                          "companyIndustry": {
                            "type": "string",
                            "nullable": true,
                            "description": "The employer's industry, or null. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits."
                          },
                          "companyEmployeeCount": {
                            "type": "integer",
                            "nullable": true,
                            "description": "The employer's reported total employee count, or null (never an invented zero). Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits. companyStaffRange is the LinkedIn size bucket and companyEmployeeCount is the reported total, so the two can disagree."
                          },
                          "companyStaffRange": {
                            "type": "string",
                            "nullable": true,
                            "description": "The employer's LinkedIn size bucket, for example \"51-200\", or null. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits. companyStaffRange is the LinkedIn size bucket and companyEmployeeCount is the reported total, so the two can disagree."
                          },
                          "companyEnrichedAt": {
                            "type": "string",
                            "format": "date-time",
                            "nullable": true,
                            "description": "The newest company-record resolution time across this person's leads (ISO 8601), or null. companyEnrichedAt is null until the company has been resolved; after that, a null company field means the company record has no value for it. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits."
                          },
                          "country": {
                            "type": "string",
                            "nullable": true
                          },
                          "isIcp": {
                            "type": "boolean"
                          },
                          "engagementCount": {
                            "type": "integer",
                            "description": "Total engagement rows for this person on the team's tracked content: likes, comments and, from keyword searches with capturePostAuthors on, Author rows for posts they wrote. likeCount + commentCount is the engagement subset, so an author-only person has engagementCount 1 and both of those 0."
                          },
                          "likeCount": {
                            "type": "integer"
                          },
                          "commentCount": {
                            "type": "integer"
                          },
                          "postCount": {
                            "type": "integer",
                            "description": "Distinct posts engaged with."
                          },
                          "firstEngagedAt": {
                            "type": "string",
                            "format": "date-time",
                            "nullable": true
                          },
                          "lastEngagedAt": {
                            "type": "string",
                            "format": "date-time",
                            "nullable": true
                          }
                        }
                      }
                    },
                    "total": {
                      "type": "integer",
                      "description": "Distinct engagers matching the filters, counted over the whole filtered set and INDEPENDENTLY of the page requested \u2014 so it is the same number on every page, including an empty one past the end."
                    },
                    "limit": {
                      "type": "integer"
                    },
                    "offset": {
                      "type": "integer"
                    },
                    "hasMore": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid query parameter.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/SubscriptionInactive"
          },
          "404": {
            "description": "Scoped source not found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/profile/{username}/webhook": {
      "get": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Get webhook config for a tracked personal LinkedIn profile",
        "operationId": "getProfileWebhookConfig",
        "description": "Read the webhook configuration (delivery URL and flags) for a tracked personal LinkedIn profile.",
        "parameters": [
          {
            "name": "username",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/LinkedInUsername"
            },
            "description": "Public identifier of the tracked personal profile (not a full URL), e.g. demo-profile."
          }
        ],
        "responses": {
          "200": {
            "description": "Current webhook configuration for the tracked personal LinkedIn profile.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "username",
                    "profileType",
                    "webhook"
                  ],
                  "properties": {
                    "username": {
                      "type": "string"
                    },
                    "profileType": {
                      "type": "string",
                      "enum": [
                        "person",
                        "company"
                      ]
                    },
                    "webhook": {
                      "type": "object",
                      "properties": {
                        "webhookUrl": {
                          "type": "string",
                          "nullable": true,
                          "description": "Where leads are POSTed. Null = no webhook."
                        },
                        "icpOnly": {
                          "type": "boolean",
                          "description": "Only deliver leads matching the source's ICP filter."
                        },
                        "autoSend": {
                          "type": "boolean",
                          "description": "Auto-deliver new leads (true) or deliver only on explicit push (false)."
                        },
                        "syncEvents": {
                          "type": "boolean",
                          "description": "Also send signed sync.completed/sync.failed lifecycle callbacks."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/MissingUsername"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TrackedProfileDeleteForbidden"
          },
          "404": {
            "$ref": "#/components/responses/ProfileNotTracked"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "put": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Configure the webhook for a tracked personal LinkedIn profile",
        "operationId": "setProfileWebhookConfig",
        "description": "Configure how leads are delivered OUT of Cornersight for a tracked personal LinkedIn profile \u2014 previously a dashboard-only capability. Partial update: only fields present in the body change. webhookUrl must be a public https URL; an empty string or null clears it. syncEvents requires a webhookUrl. UNKNOWN FIELDS ARE REFUSED: a property not listed here is a 400 carrying `code: \"unknown_field\"` and naming the offending field, rather than a 200 that silently dropped it.",
        "parameters": [
          {
            "name": "username",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/LinkedInUsername"
            },
            "description": "Public identifier of the tracked personal profile (not a full URL), e.g. demo-profile."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "description": "Partial update \u2014 only the fields present change.",
                "properties": {
                  "webhookUrl": {
                    "type": "string",
                    "description": "Public https URL to POST leads to. Empty string or null clears it (localhost/private hosts are rejected)."
                  },
                  "icpOnly": {
                    "type": "boolean"
                  },
                  "autoSend": {
                    "type": "boolean"
                  },
                  "syncEvents": {
                    "type": "boolean",
                    "description": "Requires a webhookUrl to deliver to."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated webhook configuration.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "username",
                    "profileType",
                    "webhook"
                  ],
                  "properties": {
                    "username": {
                      "type": "string"
                    },
                    "profileType": {
                      "type": "string",
                      "enum": [
                        "person",
                        "company"
                      ]
                    },
                    "webhook": {
                      "type": "object",
                      "properties": {
                        "webhookUrl": {
                          "type": "string",
                          "nullable": true,
                          "description": "Where leads are POSTed. Null = no webhook."
                        },
                        "icpOnly": {
                          "type": "boolean",
                          "description": "Only deliver leads matching the source's ICP filter."
                        },
                        "autoSend": {
                          "type": "boolean",
                          "description": "Auto-deliver new leads (true) or deliver only on explicit push (false)."
                        },
                        "syncEvents": {
                          "type": "boolean",
                          "description": "Also send signed sync.completed/sync.failed lifecycle callbacks."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/MissingUsername"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TrackedProfileDeleteForbidden"
          },
          "404": {
            "$ref": "#/components/responses/ProfileNotTracked"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/company/{username}/webhook": {
      "get": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Get webhook config for a tracked LinkedIn company page",
        "operationId": "getCompanyWebhookConfig",
        "description": "Read the webhook configuration (delivery URL and flags) for a tracked LinkedIn company page.",
        "parameters": [
          {
            "name": "username",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/LinkedInUsername"
            },
            "description": "Public identifier of the tracked personal profile (not a full URL), e.g. demo-profile."
          }
        ],
        "responses": {
          "200": {
            "description": "Current webhook configuration for the tracked LinkedIn company page.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "username",
                    "profileType",
                    "webhook"
                  ],
                  "properties": {
                    "username": {
                      "type": "string"
                    },
                    "profileType": {
                      "type": "string",
                      "enum": [
                        "person",
                        "company"
                      ]
                    },
                    "webhook": {
                      "type": "object",
                      "properties": {
                        "webhookUrl": {
                          "type": "string",
                          "nullable": true,
                          "description": "Where leads are POSTed. Null = no webhook."
                        },
                        "icpOnly": {
                          "type": "boolean",
                          "description": "Only deliver leads matching the source's ICP filter."
                        },
                        "autoSend": {
                          "type": "boolean",
                          "description": "Auto-deliver new leads (true) or deliver only on explicit push (false)."
                        },
                        "syncEvents": {
                          "type": "boolean",
                          "description": "Also send signed sync.completed/sync.failed lifecycle callbacks."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/MissingUsername"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TrackedProfileDeleteForbidden"
          },
          "404": {
            "$ref": "#/components/responses/ProfileNotTracked"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "put": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Configure the webhook for a tracked LinkedIn company page",
        "operationId": "setCompanyWebhookConfig",
        "description": "Configure how leads are delivered OUT of Cornersight for a tracked LinkedIn company page \u2014 previously a dashboard-only capability. Partial update: only fields present in the body change. webhookUrl must be a public https URL; an empty string or null clears it. syncEvents requires a webhookUrl. UNKNOWN FIELDS ARE REFUSED: a property not listed here is a 400 carrying `code: \"unknown_field\"` and naming the offending field, rather than a 200 that silently dropped it.",
        "parameters": [
          {
            "name": "username",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/LinkedInUsername"
            },
            "description": "Public identifier of the tracked personal profile (not a full URL), e.g. demo-profile."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "description": "Partial update \u2014 only the fields present change.",
                "properties": {
                  "webhookUrl": {
                    "type": "string",
                    "description": "Public https URL to POST leads to. Empty string or null clears it (localhost/private hosts are rejected)."
                  },
                  "icpOnly": {
                    "type": "boolean"
                  },
                  "autoSend": {
                    "type": "boolean"
                  },
                  "syncEvents": {
                    "type": "boolean",
                    "description": "Requires a webhookUrl to deliver to."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated webhook configuration.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "username",
                    "profileType",
                    "webhook"
                  ],
                  "properties": {
                    "username": {
                      "type": "string"
                    },
                    "profileType": {
                      "type": "string",
                      "enum": [
                        "person",
                        "company"
                      ]
                    },
                    "webhook": {
                      "type": "object",
                      "properties": {
                        "webhookUrl": {
                          "type": "string",
                          "nullable": true,
                          "description": "Where leads are POSTed. Null = no webhook."
                        },
                        "icpOnly": {
                          "type": "boolean",
                          "description": "Only deliver leads matching the source's ICP filter."
                        },
                        "autoSend": {
                          "type": "boolean",
                          "description": "Auto-deliver new leads (true) or deliver only on explicit push (false)."
                        },
                        "syncEvents": {
                          "type": "boolean",
                          "description": "Also send signed sync.completed/sync.failed lifecycle callbacks."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/MissingUsername"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TrackedProfileDeleteForbidden"
          },
          "404": {
            "$ref": "#/components/responses/ProfileNotTracked"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/keyword/{id}/webhook": {
      "get": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Get webhook config for a tracked keyword search",
        "operationId": "getKeywordWebhookConfig",
        "description": "Read the webhook configuration (delivery URL and flags) for a tracked KEYWORD SEARCH. Addressed by SOURCE ID, not by keyword text: a keyword search's identifier in GET /api/v1/sources is `id`, while its `username` is the free-text keywords it searches for. Same webhook and same delivery as every other source kind - a keyword search's `lead.detected` has always fired; only reading and setting it was dashboard-only. Its ICP config and sync status are reachable the same way, at /api/v1/keyword/{id}/icp and /api/v1/keyword/{id}/sync.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The keyword search's source id, from GET /api/v1/sources. A keyword search is addressed by id, not by its keyword text."
          }
        ],
        "responses": {
          "200": {
            "description": "Current webhook configuration for the tracked keyword search.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "username",
                    "profileType",
                    "webhook"
                  ],
                  "properties": {
                    "username": {
                      "type": "string"
                    },
                    "profileType": {
                      "type": "string",
                      "enum": [
                        "keyword"
                      ]
                    },
                    "webhook": {
                      "type": "object",
                      "properties": {
                        "webhookUrl": {
                          "type": "string",
                          "nullable": true,
                          "description": "Where leads are POSTed. Null = no webhook."
                        },
                        "icpOnly": {
                          "type": "boolean",
                          "description": "Only deliver leads matching the source's ICP filter."
                        },
                        "autoSend": {
                          "type": "boolean",
                          "description": "Auto-deliver new leads (true) or deliver only on explicit push (false)."
                        },
                        "syncEvents": {
                          "type": "boolean",
                          "description": "Also send signed sync.completed/sync.failed lifecycle callbacks."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The path segment is not a source id, or the body is invalid."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TrackedProfileDeleteForbidden"
          },
          "404": {
            "description": "No keyword search with that id for this team."
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "put": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Configure the webhook for a tracked keyword search",
        "operationId": "setKeywordWebhookConfig",
        "description": "Configure how leads are delivered OUT of Cornersight for a tracked KEYWORD SEARCH - previously a dashboard-only capability. Addressed by SOURCE ID (from GET /api/v1/sources), not by keyword text. Partial update: only fields present in the body change. webhookUrl must be a public https URL; an empty string or null clears it. syncEvents requires a destination. Its ICP config and sync status are reachable the same way, at /api/v1/keyword/{id}/icp and /api/v1/keyword/{id}/sync. UNKNOWN FIELDS ARE REFUSED: a property not listed here is a 400 carrying `code: \"unknown_field\"` and naming the offending field, rather than a 200 that silently dropped it.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The keyword search's source id, from GET /api/v1/sources. A keyword search is addressed by id, not by its keyword text."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "description": "Partial update \u2014 only the fields present change.",
                "properties": {
                  "webhookUrl": {
                    "type": "string",
                    "description": "Public https URL to POST leads to. Empty string or null clears it (localhost/private hosts are rejected)."
                  },
                  "icpOnly": {
                    "type": "boolean"
                  },
                  "autoSend": {
                    "type": "boolean"
                  },
                  "syncEvents": {
                    "type": "boolean",
                    "description": "Requires a webhookUrl to deliver to."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated webhook configuration.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "username",
                    "profileType",
                    "webhook"
                  ],
                  "properties": {
                    "username": {
                      "type": "string"
                    },
                    "profileType": {
                      "type": "string",
                      "enum": [
                        "keyword"
                      ]
                    },
                    "webhook": {
                      "type": "object",
                      "properties": {
                        "webhookUrl": {
                          "type": "string",
                          "nullable": true,
                          "description": "Where leads are POSTed. Null = no webhook."
                        },
                        "icpOnly": {
                          "type": "boolean",
                          "description": "Only deliver leads matching the source's ICP filter."
                        },
                        "autoSend": {
                          "type": "boolean",
                          "description": "Auto-deliver new leads (true) or deliver only on explicit push (false)."
                        },
                        "syncEvents": {
                          "type": "boolean",
                          "description": "Also send signed sync.completed/sync.failed lifecycle callbacks."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The path segment is not a source id, or the body is invalid."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/TrackedProfileDeleteForbidden"
          },
          "404": {
            "description": "No keyword search with that id for this team."
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/profile/{username}/icp": {
      "get": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Get ICP criteria for a tracked personal LinkedIn profile",
        "operationId": "getProfileIcp",
        "description": "Read the ICP (Ideal Customer Profile) criteria \u2014 filter rules + match mode \u2014 that decide which of this source's leads are isIcp (and, with a webhook's icpOnly, which get delivered). Previously dashboard-only.",
        "parameters": [
          {
            "name": "username",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/LinkedInUsername"
            },
            "description": "Public identifier of the tracked personal profile (not a full URL), e.g. demo-profile."
          }
        ],
        "responses": {
          "200": {
            "description": "Current ICP configuration for the tracked personal LinkedIn profile.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "username",
                    "profileType",
                    "icp"
                  ],
                  "properties": {
                    "username": {
                      "type": "string"
                    },
                    "profileType": {
                      "type": "string",
                      "enum": [
                        "person",
                        "company"
                      ]
                    },
                    "icp": {
                      "type": "object",
                      "required": [
                        "matchMode",
                        "rules"
                      ],
                      "properties": {
                        "matchMode": {
                          "type": "string",
                          "enum": [
                            "all",
                            "any"
                          ],
                          "description": "How rule groups combine: 'all' = AND (default), 'any' = OR."
                        },
                        "rules": {
                          "type": "array",
                          "description": "ICP filter rules. Within a column rules OR together; matchMode decides across columns. [] means no ICP filter.",
                          "items": {
                            "type": "object",
                            "required": [
                              "column",
                              "operator",
                              "value"
                            ],
                            "properties": {
                              "column": {
                                "type": "string",
                                "enum": [
                                  "name",
                                  "jobTitle",
                                  "company",
                                  "country",
                                  "engagementType",
                                  "date"
                                ]
                              },
                              "operator": {
                                "type": "string",
                                "enum": [
                                  "contains",
                                  "equals",
                                  "not_contains",
                                  "not_equals",
                                  "date_from",
                                  "date_to"
                                ]
                              },
                              "value": {
                                "type": "string"
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/MissingUsername"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/ProfileNotTracked"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "put": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Set ICP criteria for a tracked personal LinkedIn profile",
        "operationId": "setProfileIcp",
        "description": "Set the ICP (Ideal Customer Profile) criteria for a tracked personal LinkedIn profile \u2014 previously dashboard-only. Partial update: pass rules and/or matchMode. rules of [] or null clears the filter. Existing leads are re-scored immediately so isIcp and icpOnly delivery reflect the change. `dryRun: true` ANSWERS \u201cWHAT WOULD THIS SELECT?\u201d WITHOUT APPLYING IT: validate, evaluate against this source's existing leads, and answer 200 with `counts` \u2014 `{ matching, notMatching }` over every lead the source has captured \u2014 a `sample` of up to ten matching leads by `id` and `name`, the `icp` (`matchMode` and `rules`) that was evaluated, and `dryRun: true`. Nothing is written and nothing fires. Use it before any rule change on a source whose webhook delivers `icpOnly` or whose pushes use `scope: \"icp\"`, because saving re-scores every existing lead immediately. UNKNOWN FIELDS ARE REFUSED: a property not listed here is a 400 carrying `code: \"unknown_field\"` and naming the offending field, rather than a 200 that silently dropped it.",
        "parameters": [
          {
            "name": "username",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/LinkedInUsername"
            },
            "description": "Public identifier of the tracked personal profile (not a full URL), e.g. demo-profile."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "description": "Partial update \u2014 pass rules and/or matchMode (at least one), or `dryRun: true` alone to evaluate the configuration already stored. No other property is accepted.",
                "additionalProperties": false,
                "properties": {
                  "dryRun": {
                    "type": "boolean",
                    "default": false,
                    "description": "Evaluate this rule set against the source's ALREADY-CAPTURED leads and CHANGE NOTHING: no rules are written, no lead is re-scored, and no webhook or push fires. Same meaning as `dryRun` on the push endpoints. The response carries `dryRun: true` plus `counts` and a `sample`, so a caller can assert on that flag before trusting that a live source was left alone. THE CONFIG EVALUATED IS THE EFFECTIVE ONE \u2014 the fields in this body over the ones already stored, which is what saving would leave behind \u2014 so `{\"matchMode\":\"any\",\"dryRun\":true}` scores the STORED rules under the new mode, and `{\"dryRun\":true}` alone evaluates the configuration as it stands. Validation runs first, so a rule set this endpoint would refuse is refused here too rather than previewed."
                  },
                  "matchMode": {
                    "type": "string",
                    "enum": [
                      "all",
                      "any"
                    ],
                    "description": "How rule groups combine: 'all' = AND (default), 'any' = OR."
                  },
                  "rules": {
                    "type": "array",
                    "nullable": true,
                    "description": "The ICP filter rules. [] or null clears the filter.",
                    "items": {
                      "type": "object",
                      "required": [
                        "column",
                        "operator",
                        "value"
                      ],
                      "properties": {
                        "column": {
                          "type": "string",
                          "enum": [
                            "name",
                            "jobTitle",
                            "company",
                            "country",
                            "engagementType",
                            "date"
                          ]
                        },
                        "operator": {
                          "type": "string",
                          "enum": [
                            "contains",
                            "equals",
                            "not_contains",
                            "not_equals",
                            "date_from",
                            "date_to"
                          ]
                        },
                        "value": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated ICP configuration.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "username",
                    "profileType",
                    "icp"
                  ],
                  "properties": {
                    "dryRun": {
                      "type": "boolean",
                      "enum": [
                        true
                      ],
                      "description": "PRESENT, AND ALWAYS `true`, ONLY ON A DRY RUN \u2014 absent on a PUT that actually wrote. Its presence is the assertion that nothing was changed and nothing fired; `icp` then reports the configuration that WOULD have been saved rather than the one that was."
                    },
                    "counts": {
                      "type": "object",
                      "description": "Dry runs only. How this source's already-captured leads split under the evaluated configuration. The two add up to every lead the source has captured, and they are produced by the SAME pass that re-scores `isIcp` on a real save \u2014 so the preview cannot disagree with what saving does.",
                      "required": [
                        "matching",
                        "notMatching"
                      ],
                      "properties": {
                        "matching": {
                          "type": "integer",
                          "description": "Leads that WOULD be marked `isIcp: true`."
                        },
                        "notMatching": {
                          "type": "integer",
                          "description": "Leads that would NOT \u2014 the cohort an `icpOnly` webhook would stop delivering and a `scope: \"icp\"` push would stop selecting."
                        }
                      }
                    },
                    "sample": {
                      "type": "array",
                      "description": "Dry runs only. Up to ten of the matching leads, so the counts can be checked against real people. A SAMPLE, not a page: no cursor and no ordering guarantee \u2014 use GET /api/v1/leads?isIcp=true after saving for the full set.",
                      "items": {
                        "type": "object",
                        "required": [
                          "id",
                          "name"
                        ],
                        "properties": {
                          "id": {
                            "type": "string",
                            "format": "uuid",
                            "description": "The lead id, exactly as GET /api/v1/leads reports it."
                          },
                          "name": {
                            "type": "string",
                            "nullable": true,
                            "description": "The lead's name, or null when enrichment has not resolved one."
                          }
                        }
                      }
                    },
                    "username": {
                      "type": "string"
                    },
                    "profileType": {
                      "type": "string",
                      "enum": [
                        "person",
                        "company"
                      ]
                    },
                    "icp": {
                      "type": "object",
                      "required": [
                        "matchMode",
                        "rules"
                      ],
                      "properties": {
                        "matchMode": {
                          "type": "string",
                          "enum": [
                            "all",
                            "any"
                          ]
                        },
                        "rules": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "required": [
                              "column",
                              "operator",
                              "value"
                            ],
                            "properties": {
                              "column": {
                                "type": "string"
                              },
                              "operator": {
                                "type": "string"
                              },
                              "value": {
                                "type": "string"
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/MissingUsername"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/ProfileNotTracked"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/company/{username}/icp": {
      "get": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Get ICP criteria for a tracked LinkedIn company page",
        "operationId": "getCompanyIcp",
        "description": "Read the ICP (Ideal Customer Profile) criteria \u2014 filter rules + match mode \u2014 for a tracked LinkedIn company page.",
        "parameters": [
          {
            "name": "username",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/LinkedInUsername"
            },
            "description": "Public identifier of the tracked company page (not a full URL), e.g. demo-company."
          }
        ],
        "responses": {
          "200": {
            "description": "Current ICP configuration for the tracked LinkedIn company page.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "username",
                    "profileType",
                    "icp"
                  ],
                  "properties": {
                    "username": {
                      "type": "string"
                    },
                    "profileType": {
                      "type": "string",
                      "enum": [
                        "person",
                        "company"
                      ]
                    },
                    "icp": {
                      "type": "object",
                      "required": [
                        "matchMode",
                        "rules"
                      ],
                      "properties": {
                        "matchMode": {
                          "type": "string",
                          "enum": [
                            "all",
                            "any"
                          ]
                        },
                        "rules": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "required": [
                              "column",
                              "operator",
                              "value"
                            ],
                            "properties": {
                              "column": {
                                "type": "string"
                              },
                              "operator": {
                                "type": "string"
                              },
                              "value": {
                                "type": "string"
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/MissingUsername"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/ProfileNotTracked"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "put": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Set ICP criteria for a tracked LinkedIn company page",
        "operationId": "setCompanyIcp",
        "description": "Set the ICP criteria for a tracked LinkedIn company page. Partial update: pass rules and/or matchMode. rules of [] or null clears the filter. Existing leads are re-scored immediately. `dryRun: true` ANSWERS \u201cWHAT WOULD THIS SELECT?\u201d WITHOUT APPLYING IT: validate, evaluate against this source's existing leads, and answer 200 with `counts` \u2014 `{ matching, notMatching }` over every lead the source has captured \u2014 a `sample` of up to ten matching leads by `id` and `name`, the `icp` (`matchMode` and `rules`) that was evaluated, and `dryRun: true`. Nothing is written and nothing fires. Use it before any rule change on a source whose webhook delivers `icpOnly` or whose pushes use `scope: \"icp\"`, because saving re-scores every existing lead immediately. UNKNOWN FIELDS ARE REFUSED: a property not listed here is a 400 carrying `code: \"unknown_field\"` and naming the offending field, rather than a 200 that silently dropped it.",
        "parameters": [
          {
            "name": "username",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/LinkedInUsername"
            },
            "description": "Public identifier of the tracked company page (not a full URL), e.g. demo-company."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "description": "Partial update \u2014 pass rules and/or matchMode (at least one), or `dryRun: true` alone to evaluate the configuration already stored. No other property is accepted.",
                "additionalProperties": false,
                "properties": {
                  "dryRun": {
                    "type": "boolean",
                    "default": false,
                    "description": "Evaluate this rule set against the source's ALREADY-CAPTURED leads and CHANGE NOTHING: no rules are written, no lead is re-scored, and no webhook or push fires. Same meaning as `dryRun` on the push endpoints. The response carries `dryRun: true` plus `counts` and a `sample`, so a caller can assert on that flag before trusting that a live source was left alone. THE CONFIG EVALUATED IS THE EFFECTIVE ONE \u2014 the fields in this body over the ones already stored, which is what saving would leave behind \u2014 so `{\"matchMode\":\"any\",\"dryRun\":true}` scores the STORED rules under the new mode, and `{\"dryRun\":true}` alone evaluates the configuration as it stands. Validation runs first, so a rule set this endpoint would refuse is refused here too rather than previewed."
                  },
                  "matchMode": {
                    "type": "string",
                    "enum": [
                      "all",
                      "any"
                    ]
                  },
                  "rules": {
                    "type": "array",
                    "nullable": true,
                    "description": "The ICP filter rules. [] or null clears the filter.",
                    "items": {
                      "type": "object",
                      "required": [
                        "column",
                        "operator",
                        "value"
                      ],
                      "properties": {
                        "column": {
                          "type": "string"
                        },
                        "operator": {
                          "type": "string"
                        },
                        "value": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated ICP configuration.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "username",
                    "profileType",
                    "icp"
                  ],
                  "properties": {
                    "dryRun": {
                      "type": "boolean",
                      "enum": [
                        true
                      ],
                      "description": "PRESENT, AND ALWAYS `true`, ONLY ON A DRY RUN \u2014 absent on a PUT that actually wrote. Its presence is the assertion that nothing was changed and nothing fired; `icp` then reports the configuration that WOULD have been saved rather than the one that was."
                    },
                    "counts": {
                      "type": "object",
                      "description": "Dry runs only. How this source's already-captured leads split under the evaluated configuration. The two add up to every lead the source has captured, and they are produced by the SAME pass that re-scores `isIcp` on a real save \u2014 so the preview cannot disagree with what saving does.",
                      "required": [
                        "matching",
                        "notMatching"
                      ],
                      "properties": {
                        "matching": {
                          "type": "integer",
                          "description": "Leads that WOULD be marked `isIcp: true`."
                        },
                        "notMatching": {
                          "type": "integer",
                          "description": "Leads that would NOT \u2014 the cohort an `icpOnly` webhook would stop delivering and a `scope: \"icp\"` push would stop selecting."
                        }
                      }
                    },
                    "sample": {
                      "type": "array",
                      "description": "Dry runs only. Up to ten of the matching leads, so the counts can be checked against real people. A SAMPLE, not a page: no cursor and no ordering guarantee \u2014 use GET /api/v1/leads?isIcp=true after saving for the full set.",
                      "items": {
                        "type": "object",
                        "required": [
                          "id",
                          "name"
                        ],
                        "properties": {
                          "id": {
                            "type": "string",
                            "format": "uuid",
                            "description": "The lead id, exactly as GET /api/v1/leads reports it."
                          },
                          "name": {
                            "type": "string",
                            "nullable": true,
                            "description": "The lead's name, or null when enrichment has not resolved one."
                          }
                        }
                      }
                    },
                    "username": {
                      "type": "string"
                    },
                    "profileType": {
                      "type": "string",
                      "enum": [
                        "person",
                        "company"
                      ]
                    },
                    "icp": {
                      "type": "object",
                      "required": [
                        "matchMode",
                        "rules"
                      ],
                      "properties": {
                        "matchMode": {
                          "type": "string",
                          "enum": [
                            "all",
                            "any"
                          ]
                        },
                        "rules": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "required": [
                              "column",
                              "operator",
                              "value"
                            ],
                            "properties": {
                              "column": {
                                "type": "string"
                              },
                              "operator": {
                                "type": "string"
                              },
                              "value": {
                                "type": "string"
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/MissingUsername"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/ProfileNotTracked"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/keyword/{id}/icp": {
      "get": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Get ICP criteria for a tracked keyword search",
        "operationId": "getKeywordIcp",
        "description": "Read the ICP criteria (filter rules and match mode) for a tracked KEYWORD SEARCH. Addressed by SOURCE ID, not by keyword text: a keyword search's identifier in GET /api/v1/sources is `id`, while its `username` there is the free-text terms it searches for. The rules are the same ones capture evaluates: evaluateIcp reads icp_filter_rules off the source row without regard for source kind, so a keyword search's leads have always been scored against them - only reading and setting them was dashboard-only.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The keyword search's source id, from GET /api/v1/sources. A keyword search is addressed by id, not by its keyword text."
          }
        ],
        "responses": {
          "200": {
            "description": "Current ICP configuration for the tracked keyword search.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "username",
                    "profileType",
                    "icp"
                  ],
                  "properties": {
                    "username": {
                      "type": "string"
                    },
                    "profileType": {
                      "type": "string",
                      "enum": [
                        "keyword"
                      ]
                    },
                    "icp": {
                      "type": "object",
                      "required": [
                        "matchMode",
                        "rules"
                      ],
                      "properties": {
                        "matchMode": {
                          "type": "string",
                          "enum": [
                            "all",
                            "any"
                          ]
                        },
                        "rules": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "required": [
                              "column",
                              "operator",
                              "value"
                            ],
                            "properties": {
                              "column": {
                                "type": "string"
                              },
                              "operator": {
                                "type": "string"
                              },
                              "value": {
                                "type": "string"
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The path segment is not a source id, or the body is invalid."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "No keyword search with that id for this team."
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "put": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Set ICP criteria for a tracked keyword search",
        "operationId": "setKeywordIcp",
        "description": "Set the ICP criteria for a tracked KEYWORD SEARCH (rules and/or match mode). Re-scores existing leads' isIcp. Addressed by SOURCE ID, not by keyword text: a keyword search's identifier in GET /api/v1/sources is `id`, while its `username` there is the free-text terms it searches for. Pass rules: [] to clear the filter. `dryRun: true` ANSWERS \u201cWHAT WOULD THIS SELECT?\u201d WITHOUT APPLYING IT: validate, evaluate against this source's existing leads, and answer 200 with `counts` \u2014 `{ matching, notMatching }` over every lead the source has captured \u2014 a `sample` of up to ten matching leads by `id` and `name`, the `icp` (`matchMode` and `rules`) that was evaluated, and `dryRun: true`. Nothing is written and nothing fires. Use it before any rule change on a source whose webhook delivers `icpOnly` or whose pushes use `scope: \"icp\"`, because saving re-scores every existing lead immediately. UNKNOWN FIELDS ARE REFUSED: a property not listed here is a 400 carrying `code: \"unknown_field\"` and naming the offending field, rather than a 200 that silently dropped it.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The keyword search's source id, from GET /api/v1/sources. A keyword search is addressed by id, not by its keyword text."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "description": "Partial update \u2014 pass rules and/or matchMode (at least one), or `dryRun: true` alone to evaluate the configuration already stored. No other property is accepted.",
                "additionalProperties": false,
                "properties": {
                  "dryRun": {
                    "type": "boolean",
                    "default": false,
                    "description": "Evaluate this rule set against the source's ALREADY-CAPTURED leads and CHANGE NOTHING: no rules are written, no lead is re-scored, and no webhook or push fires. Same meaning as `dryRun` on the push endpoints. The response carries `dryRun: true` plus `counts` and a `sample`, so a caller can assert on that flag before trusting that a live source was left alone. THE CONFIG EVALUATED IS THE EFFECTIVE ONE \u2014 the fields in this body over the ones already stored, which is what saving would leave behind \u2014 so `{\"matchMode\":\"any\",\"dryRun\":true}` scores the STORED rules under the new mode, and `{\"dryRun\":true}` alone evaluates the configuration as it stands. Validation runs first, so a rule set this endpoint would refuse is refused here too rather than previewed."
                  },
                  "matchMode": {
                    "type": "string",
                    "enum": [
                      "all",
                      "any"
                    ]
                  },
                  "rules": {
                    "type": "array",
                    "nullable": true,
                    "description": "The ICP filter rules. [] or null clears the filter.",
                    "items": {
                      "type": "object",
                      "required": [
                        "column",
                        "operator",
                        "value"
                      ],
                      "properties": {
                        "column": {
                          "type": "string"
                        },
                        "operator": {
                          "type": "string"
                        },
                        "value": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated ICP configuration.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "username",
                    "profileType",
                    "icp"
                  ],
                  "properties": {
                    "dryRun": {
                      "type": "boolean",
                      "enum": [
                        true
                      ],
                      "description": "PRESENT, AND ALWAYS `true`, ONLY ON A DRY RUN \u2014 absent on a PUT that actually wrote. Its presence is the assertion that nothing was changed and nothing fired; `icp` then reports the configuration that WOULD have been saved rather than the one that was."
                    },
                    "counts": {
                      "type": "object",
                      "description": "Dry runs only. How this source's already-captured leads split under the evaluated configuration. The two add up to every lead the source has captured, and they are produced by the SAME pass that re-scores `isIcp` on a real save \u2014 so the preview cannot disagree with what saving does.",
                      "required": [
                        "matching",
                        "notMatching"
                      ],
                      "properties": {
                        "matching": {
                          "type": "integer",
                          "description": "Leads that WOULD be marked `isIcp: true`."
                        },
                        "notMatching": {
                          "type": "integer",
                          "description": "Leads that would NOT \u2014 the cohort an `icpOnly` webhook would stop delivering and a `scope: \"icp\"` push would stop selecting."
                        }
                      }
                    },
                    "sample": {
                      "type": "array",
                      "description": "Dry runs only. Up to ten of the matching leads, so the counts can be checked against real people. A SAMPLE, not a page: no cursor and no ordering guarantee \u2014 use GET /api/v1/leads?isIcp=true after saving for the full set.",
                      "items": {
                        "type": "object",
                        "required": [
                          "id",
                          "name"
                        ],
                        "properties": {
                          "id": {
                            "type": "string",
                            "format": "uuid",
                            "description": "The lead id, exactly as GET /api/v1/leads reports it."
                          },
                          "name": {
                            "type": "string",
                            "nullable": true,
                            "description": "The lead's name, or null when enrichment has not resolved one."
                          }
                        }
                      }
                    },
                    "username": {
                      "type": "string"
                    },
                    "profileType": {
                      "type": "string",
                      "enum": [
                        "keyword"
                      ]
                    },
                    "icp": {
                      "type": "object",
                      "required": [
                        "matchMode",
                        "rules"
                      ],
                      "properties": {
                        "matchMode": {
                          "type": "string",
                          "enum": [
                            "all",
                            "any"
                          ]
                        },
                        "rules": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "required": [
                              "column",
                              "operator",
                              "value"
                            ],
                            "properties": {
                              "column": {
                                "type": "string"
                              },
                              "operator": {
                                "type": "string"
                              },
                              "value": {
                                "type": "string"
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The path segment is not a source id, or the body is invalid."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "No keyword search with that id for this team."
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/profile/{username}/urn": {
      "get": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Resolve a public LinkedIn handle to its member id (free)",
        "operationId": "getProfileUrn",
        "description": "Resolve any public LinkedIn handle to the member id (`ACoAA\u2026`) the keyword targeting filters take. WHY IT EXISTS: `fromPerson` and `mentionsPerson` accept a member id and never a handle, and nothing else in this API could produce one \u2014 POST /api/v1/enrich/profile answers 404 for anyone who is not already one of your leads, and with `saveTrackedProfile` it TRACKS them, which queues a full sync and charges. THIS ENRICHES NOBODY, TRACKS NOBODY AND CHARGES NOTHING: no lead row is written, so no enriching credit is consumed, and the response says so as `creditsCharged: 0`. The only cost is one upstream lookup, which is cached \u2014 in flight, for the life of the worker, and by the provider for 24h \u2014 so repeating a handle is free. The standard per-team rate limit is the only cap. The handle may be given bare (`jasonlemkin`) or as a profile URL. A `404` with `code: \"not_found\"` means the provider served no member id for that handle \u2014 private, renamed or deleted \u2014 and retrying will not change it; a provider outage is a `502` carrying its own code instead.",
        "parameters": [
          {
            "name": "username",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/LinkedInUsername"
            },
            "description": "Public identifier from a profile URL (not a full URL), e.g. demo-profile."
          }
        ],
        "responses": {
          "200": {
            "description": "The member id behind the handle. Nothing was enriched, tracked or charged.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "username",
                    "memberId",
                    "urn",
                    "creditsCharged"
                  ],
                  "properties": {
                    "username": {
                      "type": "string",
                      "description": "The normalised handle that was looked up."
                    },
                    "memberId": {
                      "type": "string",
                      "example": "ACoAAA5eqCEBzmkNfjyOp9_MseBpRQ-P17SuIos",
                      "description": "The LinkedIn member id \u2014 the same value profile enrichment returns as `entityUrn`. Accepted as-is by `fromPerson` and `mentionsPerson`."
                    },
                    "urn": {
                      "type": "string",
                      "example": "urn:li:person:ACoAAA5eqCEBzmkNfjyOp9_MseBpRQ-P17SuIos",
                      "description": "The canonical wrapped form, which is how GET /api/v1/sources reports the filter whichever spelling you sent. Either field is legal input."
                    },
                    "creditsCharged": {
                      "type": "integer",
                      "enum": [
                        0
                      ],
                      "description": "Always 0. Stated in the body rather than only in the prose, because the whole objection to resolving an id through enrichment was its cost."
                    }
                  }
                },
                "example": {
                  "username": "jasonlemkin",
                  "memberId": "ACoAAA5eqCEBzmkNfjyOp9_MseBpRQ-P17SuIos",
                  "urn": "urn:li:person:ACoAAA5eqCEBzmkNfjyOp9_MseBpRQ-P17SuIos",
                  "creditsCharged": 0
                }
              }
            }
          },
          "400": {
            "description": "The handle could not be read as a LinkedIn public identifier.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "404": {
            "description": "No member id is available for that handle \u2014 the provider has no such profile, or it is private, renamed or deleted. `code: \"not_found\"`. Retrying will not change it: fix the handle.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "502": {
            "description": "The upstream lookup failed. The body carries the same `code` job status uses for provider failures; the provider's own response is never included.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/profile/{username}/posts": {
      "get": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Read a profile's posts and their text, without tracking it (1 credit per post)",
        "operationId": "readProfilePosts",
        "description": "\u26a0 ONE POST IS ONE CREDIT, AND THIS ENDPOINT USED TO BE FREE. Verified 20 September: 60 posts across four pages for a team whose credits used did not move, with nothing in sources, usage or any dashboard page. Every page of a walk is a different request body and so a different upstream cache key, so unlike the URN lookup this cannot be absorbed by caching \u2014 it was an uncharged fan-out to a paid upstream bounded only by a rate limit. \u26a0 ASK TWO THINGS BEFORE FETCHING ANYTHING: HOW MANY POSTS do you want (1-60, and one post is one credit), and is this a ONE-OFF READ or a DAILY WATCH? A one-off read is this endpoint with `posts` and `confirmSpend`. A daily watch is a posts-only tracked source \u2014 POST /api/v1/enrich/{profile,company} with `saveTrackedProfile: true`, `mode: \"posts_only\"` and `postsPerSync` \u2014 which costs up to that many credits EVERY DAY until it is untracked, appears on the profile row in the dashboard, delivers each new post as a `post.detected` webhook and is readable at GET /api/v1/sources/{id}/posts. \u26a0 WITHOUT `confirmSpend` THE CALL IS REFUSED 409 `spend_confirmation_required` AND NOTHING IS FETCHED OR CHARGED. That refusal is the useful part: it carries `estimatedCredits` (equal to `posts`), `remainingBalance` and the sentence to read out. Say the cost, wait for a yes, then re-send the identical request with `confirmSpend: true` \u2014 the round trip IS the consent. \u26a0 YOU ARE CHARGED FOR THE POSTS ACTUALLY RETURNED, which is why the refusal says \"up to\": a profile holding 12 posts asked for 60 costs 12, and one holding none costs nothing. `creditsCharged` in the body is what was actually billed. \u26a0 A BALANCE BELOW THE REQUEST IS A 402 `insufficient_enriching_credits` NAMING BOTH NUMBERS, and nothing is fetched. A partial set is deliberately not offered: serving 9 posts of a confirmed 20 would be a different call from the one that was authorised. \u26a0 THERE IS NO PAGING ANY MORE. `limit` and `paginationToken` are REFUSED with a 400 naming `posts`, because \"fetch 20\" and \"fetch 15 then 5 more\" must not be two prices for one answer \u2014 the 15-per-page walk still happens, internally. An integration written against the free surface is TOLD rather than silently charged. \u26a0 THE CHARGE LANDS IN THE ORDINARY CREDIT LEDGER under a new `posts_read` kind keyed by handle, so GET /api/v1/credits/usage shows it in `bySource` and names the handle in `byHandle`. \u26a0 IT IS STILL A READ AND IT PERSISTS NOTHING: no tracked source is created, no sync is queued, no post or lead row is written, and NO ENGAGERS ARE COLLECTED \u2014 that fan-out is where a sweep's cost and ALL of its leads come from, and it is explicitly not what this does. `captured: false` says so in the body: paying for a read does not make it a capture. It is NOT POST /api/v1/profile/posts (the TRACKED posts job), which answers 404 for an untracked handle because its job UPSERTS every post it walks against a tracked source \u2014 that is the whole reason this endpoint exists. \u26a0 A RATE LIMIT STILL BOUNDS IT AND IS NOW THE SECONDARY GUARD RATHER THAN THE ONLY ONE: 10 CALLS A MINUTE per team, shared between this endpoint and its sibling, against the 120/min ordinary reads get. A 429 MEANS CALLED-TOO-FAST, NOT OUT-OF-CREDITS \u2014 that is the 402, which has its own code. AN EMPTY `posts` ARRAY IS A REAL ANSWER and costs nothing: it is deliberately not a 404, which would be indistinguishable from a mistyped handle. An upstream that would not answer at all is a 502 carrying its own code, and that charges nothing either.",
        "parameters": [
          {
            "name": "username",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/LinkedInUsername"
            },
            "description": "Public identifier from a profile URL (not a full URL), e.g. demo-profile. NEED NOT BE TRACKED \u2014 that is the point of this endpoint."
          },
          {
            "name": "posts",
            "in": "query",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 60
            },
            "description": "How many posts to fetch, 1-60, newest first. ONE POST IS ONE CREDIT, so this is also the most this call can cost. REQUIRED, with NO DEFAULT: a default here would be a spend nobody asked for. You are charged for the posts actually returned, so a profile with fewer costs fewer. A value outside the range is a 400 naming it rather than a silent clamp."
          },
          {
            "name": "confirmSpend",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean"
            },
            "description": "Authorises the charge. Without it the call is refused 409 `spend_confirmation_required` and NOTHING is fetched or charged; that refusal carries `estimatedCredits` and `remainingBalance`. Say the cost to the person, wait for a yes, then re-send the identical request with `confirmSpend: true`. Do not send it on the first attempt to save a round trip \u2014 the round trip IS the consent. Anything other than `true` or `false` is a 400, never a silent yes."
          }
        ],
        "responses": {
          "200": {
            "description": "The posts, and what they cost. One credit per post returned.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "username",
                    "posts",
                    "creditsCharged",
                    "captured",
                    "maxDepth"
                  ],
                  "properties": {
                    "username": {
                      "type": "string",
                      "description": "The normalised handle that was read."
                    },
                    "posts": {
                      "type": "array",
                      "description": "The page, newest first. EMPTY IS A REAL ANSWER \u2014 that profile has no posts we can read.",
                      "items": {
                        "type": "object",
                        "required": [
                          "urn",
                          "url",
                          "text",
                          "postedAt",
                          "postedAtTimestamp",
                          "totalReactionCount",
                          "commentsCount",
                          "contentType"
                        ],
                        "properties": {
                          "urn": {
                            "type": "string",
                            "example": "urn:li:activity:7496817330904121344",
                            "description": "The post's URN \u2014 its identity, and what POST /api/v1/post/track takes if you later decide to capture this one post's engagers. Usually `urn:li:activity:\u2026`; a post LinkedIn keys by its ugcPost or share id (typically a video or document post) keeps that form, `urn:li:ugcPost:\u2026` or `urn:li:share:\u2026`, and is never relabelled as an activity id, which would name a different post."
                          },
                          "url": {
                            "type": "string",
                            "example": "https://www.linkedin.com/feed/update/urn:li:activity:7496817330904121344/",
                            "description": "Permalink to the post."
                          },
                          "text": {
                            "type": "string",
                            "nullable": true,
                            "description": "The post's text \u2014 the field this endpoint exists to return. NULL, never missing, when the provider served a post without any, which is normal for an image or video post. Read `null`; do not test for the key."
                          },
                          "postedAt": {
                            "type": "string",
                            "nullable": true,
                            "format": "date-time",
                            "description": "When it was posted, ISO 8601. A LinkedIn post id (activity, ugcPost or share) encodes its own creation time, and a person's post is dated by that id wherever it decodes, else by the provider's timestamp; a company post by the provider's timestamp, else its id. Null when neither gives one."
                          },
                          "postedAtTimestamp": {
                            "type": "integer",
                            "nullable": true,
                            "description": "The same instant in epoch milliseconds, for callers that would rather not parse. Null when the provider gave no timestamp."
                          },
                          "totalReactionCount": {
                            "type": "integer",
                            "nullable": true,
                            "description": "Reactions on the post AS THE PROVIDER REPORTED THEM at read time \u2014 a count, not the people. The reactors themselves are engagers and are NOT collected here. Null when the payload carried no count."
                          },
                          "commentsCount": {
                            "type": "integer",
                            "nullable": true,
                            "description": "Comments on the post as reported at read time \u2014 again a count and not the commenters. Null when the payload carried no count."
                          },
                          "contentType": {
                            "type": "string",
                            "nullable": true,
                            "enum": ["VIDEO", "IMAGE", "JOB", "LIVE_VIDEO", "DOCUMENT", "COLLABORATIVE_ARTICLE", null],
                            "description": "Post kind when the provider supplies an explicit matching type or unambiguous media. Same vocabulary as the keyword search contentType filter. Null, never omitted, when no matching type is available; plain text has no value in this vocabulary."
                          }
                        }
                      }
                    },
                    "creditsCharged": {
                      "type": "integer",
                      "description": "What this call was ACTUALLY billed \u2014 one credit per post returned, so it equals `posts.length` and may be lower than the `posts` you asked for. 0 when the walk came back empty."
                    },
                    "captured": {
                      "type": "boolean",
                      "enum": [
                        false
                      ],
                      "description": "Always false. Paying for a read does not make it a capture: no tracked source, no sync, no post row, no lead, no engagers. If you want new posts every day, track the source with `mode: \"posts_only\"`."
                    },
                    "maxDepth": {
                      "type": "integer",
                      "description": "How far into a history this read goes, in posts \u2014 the same number `posts` is capped at. Published so a caller can tell \"this is everything we serve\" from \"this profile has nothing more\"."
                    }
                  }
                },
                "example": {
                  "username": "jasonlemkin",
                  "posts": [
                    {
                      "urn": "urn:li:activity:7496817330904121344",
                      "url": "https://www.linkedin.com/feed/update/urn:li:activity:7496817330904121344/",
                      "text": "Most SaaS founders underprice for far too long. Here is the math.",
                      "postedAt": "2026-09-14T08:31:00.000Z",
                      "postedAtTimestamp": 1789000260000,
                      "totalReactionCount": 412,
                      "commentsCount": 57
                    }
                  ],
                  "nextPaginationToken": "eyJ2IjoicHIxIiwicyI6MTV9",
                  "creditsCharged": 0,
                  "captured": false,
                  "maxDepth": 60
                }
              }
            }
          },
          "400": {
            "description": "The handle could not be read as a LinkedIn public identifier, `limit` was out of range, or the `paginationToken` was not ours or was at/past `maxDepth`. The message distinguishes them.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "402": {
            "description": "The team cannot fund the request. The message names BOTH numbers \u2014 what the read needs and what is left \u2014 and NOTHING was fetched or charged: the read is refused whole rather than served short, because a partial answer is not the call that was confirmed. `code` is `insufficient_enriching_credits`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "409": {
            "description": "NOT CONFIRMED. Nothing was fetched and nothing was charged. The body carries `estimatedCredits` (equal to `posts`) and `remainingBalance` and the sentence to read to whoever is paying; re-send the identical request with `confirmSpend: true` to proceed. `code` is `spend_confirmation_required` \u2014 the same code POST /api/v1/keyword/track answers with.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "description": "This endpoint's own per-team rate budget is exhausted \u2014 tighter than the ordinary read limit, because every page costs an upstream call while charging nothing. NOT an out-of-credits condition. Retry after the window `Retry-After` names.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "502": {
            "description": "The upstream would not answer. Carries the classified code, the same one job status uses. Worth retrying, unlike a 400.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/company/{username}/posts": {
      "get": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Read a company page's posts and their text, without tracking it (1 credit per post)",
        "operationId": "readCompanyPosts",
        "description": "\u26a0 ONE POST IS ONE CREDIT, AND THIS ENDPOINT USED TO BE FREE. Verified 20 September: 60 posts across four pages for a team whose credits used did not move, with nothing in sources, usage or any dashboard page. Every page of a walk is a different request body and so a different upstream cache key, so unlike the URN lookup this cannot be absorbed by caching \u2014 it was an uncharged fan-out to a paid upstream bounded only by a rate limit. \u26a0 ASK TWO THINGS BEFORE FETCHING ANYTHING: HOW MANY POSTS do you want (1-60, and one post is one credit), and is this a ONE-OFF READ or a DAILY WATCH? A one-off read is this endpoint with `posts` and `confirmSpend`. A daily watch is a posts-only tracked source \u2014 POST /api/v1/enrich/{profile,company} with `saveTrackedProfile: true`, `mode: \"posts_only\"` and `postsPerSync` \u2014 which costs up to that many credits EVERY DAY until it is untracked, appears on the profile row in the dashboard, delivers each new post as a `post.detected` webhook and is readable at GET /api/v1/sources/{id}/posts. \u26a0 WITHOUT `confirmSpend` THE CALL IS REFUSED 409 `spend_confirmation_required` AND NOTHING IS FETCHED OR CHARGED. That refusal is the useful part: it carries `estimatedCredits` (equal to `posts`), `remainingBalance` and the sentence to read out. Say the cost, wait for a yes, then re-send the identical request with `confirmSpend: true` \u2014 the round trip IS the consent. \u26a0 YOU ARE CHARGED FOR THE POSTS ACTUALLY RETURNED, which is why the refusal says \"up to\": a page holding 12 posts asked for 60 costs 12, and one holding none costs nothing. `creditsCharged` in the body is what was actually billed. \u26a0 A BALANCE BELOW THE REQUEST IS A 402 `insufficient_enriching_credits` NAMING BOTH NUMBERS, and nothing is fetched. A partial set is deliberately not offered: serving 9 posts of a confirmed 20 would be a different call from the one that was authorised. \u26a0 THERE IS NO PAGING ANY MORE. `limit` and `paginationToken` are REFUSED with a 400 naming `posts`, because \"fetch 20\" and \"fetch 15 then 5 more\" must not be two prices for one answer \u2014 the 15-per-page walk still happens, internally. An integration written against the free surface is TOLD rather than silently charged. \u26a0 THE CHARGE LANDS IN THE ORDINARY CREDIT LEDGER under a new `posts_read` kind keyed by handle, so GET /api/v1/credits/usage shows it in `bySource` and names the handle in `byHandle`. \u26a0 IT IS STILL A READ AND IT PERSISTS NOTHING: no tracked source is created, no sync is queued, no post or lead row is written, and NO ENGAGERS ARE COLLECTED \u2014 that fan-out is where a sweep's cost and ALL of its leads come from, and it is explicitly not what this does. `captured: false` says so in the body: paying for a read does not make it a capture. It is NOT POST /api/v1/company/posts (the TRACKED posts job), which answers 404 for an untracked handle because its job UPSERTS every post it walks against a tracked source \u2014 that is the whole reason this endpoint exists. \u26a0 A RATE LIMIT STILL BOUNDS IT AND IS NOW THE SECONDARY GUARD RATHER THAN THE ONLY ONE: 10 CALLS A MINUTE per team, shared between this endpoint and its sibling, against the 120/min ordinary reads get. A 429 MEANS CALLED-TOO-FAST, NOT OUT-OF-CREDITS \u2014 that is the 402, which has its own code. AN EMPTY `posts` ARRAY IS A REAL ANSWER and costs nothing: it is deliberately not a 404, which would be indistinguishable from a mistyped handle. An upstream that would not answer at all is a 502 carrying its own code, and that charges nothing either.",
        "parameters": [
          {
            "name": "username",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/LinkedInUsername"
            },
            "description": "The COMPANY PAGE's slug from its URL, e.g. instantlyapp from linkedin.com/company/instantlyapp. A full company URL is accepted and unwrapped; a linkedin.com/in/\u2026 person URL is a 400 naming the profile read, never a company lookup. NEED NOT BE TRACKED \u2014 that is the point of this endpoint."
          },
          {
            "name": "posts",
            "in": "query",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 60
            },
            "description": "How many posts to fetch, 1-60, newest first. ONE POST IS ONE CREDIT, so this is also the most this call can cost. REQUIRED, with NO DEFAULT: a default here would be a spend nobody asked for. You are charged for the posts actually returned, so a profile with fewer costs fewer. A value outside the range is a 400 naming it rather than a silent clamp."
          },
          {
            "name": "confirmSpend",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean"
            },
            "description": "Authorises the charge. Without it the call is refused 409 `spend_confirmation_required` and NOTHING is fetched or charged; that refusal carries `estimatedCredits` and `remainingBalance`. Say the cost to the person, wait for a yes, then re-send the identical request with `confirmSpend: true`. Do not send it on the first attempt to save a round trip \u2014 the round trip IS the consent. Anything other than `true` or `false` is a 400, never a silent yes."
          }
        ],
        "responses": {
          "200": {
            "description": "The posts, and what they cost. One credit per post returned.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "username",
                    "posts",
                    "creditsCharged",
                    "captured",
                    "maxDepth"
                  ],
                  "properties": {
                    "username": {
                      "type": "string",
                      "description": "The normalised company slug that was read \u2014 unwrapped, if you sent a full company URL."
                    },
                    "posts": {
                      "type": "array",
                      "description": "The page, newest first by postedAt. A company page's feed is NOT in date order (pinned and older posts surface between newer ones), so this read looks 25 posts past `posts`, sorts what it read by time and returns the newest \u2014 the look-ahead is never charged. EMPTY IS A REAL ANSWER \u2014 that page has no posts we can read.",
                      "items": {
                        "type": "object",
                        "required": [
                          "urn",
                          "url",
                          "text",
                          "postedAt",
                          "postedAtTimestamp",
                          "totalReactionCount",
                          "commentsCount",
                          "contentType"
                        ],
                        "properties": {
                          "urn": {
                            "type": "string",
                            "example": "urn:li:activity:7496817330904121344",
                            "description": "The post's URN \u2014 its identity, and what POST /api/v1/post/track takes if you later decide to capture this one post's engagers. Usually `urn:li:activity:\u2026`; a post LinkedIn keys by its ugcPost or share id (typically a video or document post) keeps that form, `urn:li:ugcPost:\u2026` or `urn:li:share:\u2026`, and is never relabelled as an activity id, which would name a different post."
                          },
                          "url": {
                            "type": "string",
                            "example": "https://www.linkedin.com/feed/update/urn:li:activity:7496817330904121344/",
                            "description": "Permalink to the post."
                          },
                          "text": {
                            "type": "string",
                            "nullable": true,
                            "description": "The post's text \u2014 the field this endpoint exists to return. NULL, never missing, when the provider served a post without any, which is normal for an image or video post. Read `null`; do not test for the key."
                          },
                          "postedAt": {
                            "type": "string",
                            "nullable": true,
                            "format": "date-time",
                            "description": "When it was posted, ISO 8601. A LinkedIn post id (activity, ugcPost or share) encodes its own creation time, and a person's post is dated by that id wherever it decodes, else by the provider's timestamp; a company post by the provider's timestamp, else its id. Null when neither gives one."
                          },
                          "postedAtTimestamp": {
                            "type": "integer",
                            "nullable": true,
                            "description": "The same instant in epoch milliseconds, for callers that would rather not parse. Null when the provider gave no timestamp."
                          },
                          "totalReactionCount": {
                            "type": "integer",
                            "nullable": true,
                            "description": "Reactions on the post AS THE PROVIDER REPORTED THEM at read time \u2014 a count, not the people. The reactors themselves are engagers and are NOT collected here. Null when the payload carried no count."
                          },
                          "commentsCount": {
                            "type": "integer",
                            "nullable": true,
                            "description": "Comments on the post as reported at read time \u2014 again a count and not the commenters. Null when the payload carried no count."
                          },
                          "contentType": {
                            "type": "string",
                            "nullable": true,
                            "enum": ["VIDEO", "IMAGE", "JOB", "LIVE_VIDEO", "DOCUMENT", "COLLABORATIVE_ARTICLE", null],
                            "description": "Post kind when the provider supplies an explicit matching type or unambiguous media. Same vocabulary as the keyword search contentType filter. Null, never omitted, when no matching type is available; plain text has no value in this vocabulary."
                          }
                        }
                      }
                    },
                    "creditsCharged": {
                      "type": "integer",
                      "description": "What this call was ACTUALLY billed \u2014 one credit per post returned, so it equals `posts.length` and may be lower than the `posts` you asked for. 0 when the walk came back empty."
                    },
                    "captured": {
                      "type": "boolean",
                      "enum": [
                        false
                      ],
                      "description": "Always false. Paying for a read does not make it a capture: no tracked source, no sync, no post row, no lead, no engagers. If you want new posts every day, track the source with `mode: \"posts_only\"`."
                    },
                    "maxDepth": {
                      "type": "integer",
                      "description": "How far into a history this read goes, in posts \u2014 the same number `posts` is capped at. Published so a caller can tell \"this is everything we serve\" from \"this profile has nothing more\"."
                    }
                  }
                },
                "example": {
                  "username": "instantlyapp",
                  "posts": [
                    {
                      "urn": "urn:li:activity:7496817330904121344",
                      "url": "https://www.linkedin.com/feed/update/urn:li:activity:7496817330904121344/",
                      "text": "We shipped inbox placement tests today. Here is what we learned running 4,000 of them.",
                      "postedAt": "2026-09-14T08:31:00.000Z",
                      "postedAtTimestamp": 1789000260000,
                      "totalReactionCount": 412,
                      "commentsCount": 57
                    }
                  ],
                  "nextPaginationToken": "eyJ2IjoicHIxIiwicyI6MTV9",
                  "creditsCharged": 0,
                  "captured": false,
                  "maxDepth": 60
                }
              }
            }
          },
          "400": {
            "description": "The slug could not be read as a LinkedIn public identifier \u2014 a linkedin.com/in/\u2026 PERSON URL lands here and the message names GET /api/v1/profile/{username}/posts \u2014 or `limit` was out of range, or the `paginationToken` was not ours or was at/past `maxDepth`. The message distinguishes them.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "402": {
            "description": "The team cannot fund the request. The message names BOTH numbers \u2014 what the read needs and what is left \u2014 and NOTHING was fetched or charged: the read is refused whole rather than served short, because a partial answer is not the call that was confirmed. `code` is `insufficient_enriching_credits`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "409": {
            "description": "NOT CONFIRMED. Nothing was fetched and nothing was charged. The body carries `estimatedCredits` (equal to `posts`) and `remainingBalance` and the sentence to read to whoever is paying; re-send the identical request with `confirmSpend: true` to proceed. `code` is `spend_confirmation_required` \u2014 the same code POST /api/v1/keyword/track answers with.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "description": "The per-team rate budget these two posts reads SHARE is exhausted \u2014 tighter than the ordinary read limit, because every page costs an upstream call while charging nothing, and spent by GET /api/v1/profile/{username}/posts as well. NOT an out-of-credits condition. Retry after the window `Retry-After` names.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "502": {
            "description": "The upstream would not answer. Carries the classified code, the same one job status uses. Worth retrying, unlike a 400.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/profile/{username}/push": {
      "post": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Push historical leads for a tracked personal LinkedIn profile",
        "operationId": "pushProfileLeads",
        "description": "Push ALREADY-CAPTURED leads to this source's configured destination \u2014 the historical backfill / replay action that `autoSend: false` (\"deliver only on explicit push\") refers to, and the public form of the dashboard's \"select the leads to push\" panel.\n\n**Historical vs future.** A push selects on `detectedAt` over the half-open window `[since, until)`. When `until` is omitted it is stamped with the instant the push is accepted, so the cohort is closed and reproducible: everything with `detectedAt` before that instant is HISTORICAL and belongs to this push; everything at or after it is FUTURE and belongs to auto-send. A push never races capture.\n\n**ICP vs all.** `scope: \"all\"` selects every eligible lead on the source; `scope: \"icp\"` selects only those with `isIcp: true` \u2014 the same flag `GET /api/v1/leads` returns and the same one the source's ICP rules write. NOTE the interaction with the webhook's own `icpOnly` flag: `icpOnly` filters at DELIVERY time, so an `all` push against an `icpOnly: true` webhook queues everything and delivers only the ICP subset \u2014 the rest are reported as `held` by the status endpoint.\n\n**Eligibility.** Only `enriched` leads are ever selected: the delivered payload is built from enrichment fields and the delivery poller only reads enriched rows, so an un-enriched lead moved to `pending` would never be sent. The response's `notSelected` counts the leads in the same scope and window that were skipped for that reason: `awaitingEnrichment` (still being enriched \u2014 auto-send or a later push delivers them) and `enrichmentFailed` (enrichment ended without data; never deliverable).\n\n**Idempotency.** Supply `idempotencyKey` to make a retry safe: a second push with the same key returns the FIRST push's result with `replayed: true` and queues nothing. The same key with different parameters is a `409`. Without a key every call is a new push (and a re-push of an already-delivered lead is a legitimate replay \u2014 delivery is at-least-once, dedupe on `data.leadId`).\n\n**Progress.** The response carries a `pushId` and `statusUrl`; poll `GET /api/v1/push/{pushId}` for the live status distribution of exactly this push's cohort.\n\n**Size.** At most 2000 leads per push, oldest first. A larger cohort returns `truncated: true` and a `nextSince`; repeat the push with `since: nextSince` (and a NEW idempotency key) to continue.\n\n**Cost.** A push charges no credits: it re-queues leads that were already captured and enriched, and re-enriches none, so a backfill or replay of any size is free.\n\nSaving or changing a webhook URL through this API does NOT deliver history \u2014 this operation is the only public way to send it.\n\n**UNTRACKED SOURCES CANNOT BE PUSHED.** A source you untracked is not actionable: this returns `404`, the same as a source that was never tracked. Untracking is a soft delete, so its leads are still in the database \u2014 but they are out of every view AND out of every delivery, because a push is the operation that acts on leads that already exist, and sending data the product told you was gone into your CRM is the one outcome deletion has to prevent. The same rule governs this source's webhook and ICP configuration, which is what a push delivers to. KEYWORD searches are the deliberate exception, here as everywhere: stopping a search keeps its leads readable, so pushing them stays available.",
        "parameters": [
          {
            "name": "username",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/LinkedInUsername"
            },
            "description": "Public identifier of the tracked profile (not a full URL), e.g. demo-profile."
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LeadPushRequest"
              },
              "examples": {
                "allHistory": {
                  "summary": "Every enriched lead captured so far",
                  "value": {
                    "scope": "all",
                    "idempotencyKey": "backfill-2026-09-08"
                  }
                },
                "icpOnly": {
                  "summary": "Only ICP-matching leads, last 30 days",
                  "value": {
                    "scope": "icp",
                    "since": "2026-08-09T00:00:00Z",
                    "idempotencyKey": "icp-sept"
                  }
                },
                "dryRun": {
                  "summary": "Count the cohort without queueing anything",
                  "value": {
                    "scope": "icp",
                    "dryRun": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The push was accepted (or replayed). Delivery happens asynchronously; poll `statusUrl`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LeadPushResult"
                }
              }
            }
          },
          "400": {
            "description": "Invalid scope, window or idempotencyKey \u2014 or the source has no delivery destination configured."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "No such tracked source for this team \u2014 or one you untracked, which is not actionable (its leads are retained but never delivered). A stopped KEYWORD search is the exception and still pushes."
          },
          "409": {
            "description": "`idempotencyKey` was already used for a push with different parameters."
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/company/{username}/push": {
      "post": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Push historical leads for a tracked LinkedIn company page",
        "operationId": "pushCompanyLeads",
        "description": "Push ALREADY-CAPTURED leads to this source's configured destination \u2014 the historical backfill / replay action that `autoSend: false` (\"deliver only on explicit push\") refers to, and the public form of the dashboard's \"select the leads to push\" panel.\n\n**Historical vs future.** A push selects on `detectedAt` over the half-open window `[since, until)`. When `until` is omitted it is stamped with the instant the push is accepted, so the cohort is closed and reproducible: everything with `detectedAt` before that instant is HISTORICAL and belongs to this push; everything at or after it is FUTURE and belongs to auto-send. A push never races capture.\n\n**ICP vs all.** `scope: \"all\"` selects every eligible lead on the source; `scope: \"icp\"` selects only those with `isIcp: true` \u2014 the same flag `GET /api/v1/leads` returns and the same one the source's ICP rules write. NOTE the interaction with the webhook's own `icpOnly` flag: `icpOnly` filters at DELIVERY time, so an `all` push against an `icpOnly: true` webhook queues everything and delivers only the ICP subset \u2014 the rest are reported as `held` by the status endpoint.\n\n**Eligibility.** Only `enriched` leads are ever selected: the delivered payload is built from enrichment fields and the delivery poller only reads enriched rows, so an un-enriched lead moved to `pending` would never be sent. The response's `notSelected` counts the leads in the same scope and window that were skipped for that reason: `awaitingEnrichment` (still being enriched \u2014 auto-send or a later push delivers them) and `enrichmentFailed` (enrichment ended without data; never deliverable).\n\n**Idempotency.** Supply `idempotencyKey` to make a retry safe: a second push with the same key returns the FIRST push's result with `replayed: true` and queues nothing. The same key with different parameters is a `409`. Without a key every call is a new push (and a re-push of an already-delivered lead is a legitimate replay \u2014 delivery is at-least-once, dedupe on `data.leadId`).\n\n**Progress.** The response carries a `pushId` and `statusUrl`; poll `GET /api/v1/push/{pushId}` for the live status distribution of exactly this push's cohort.\n\n**Size.** At most 2000 leads per push, oldest first. A larger cohort returns `truncated: true` and a `nextSince`; repeat the push with `since: nextSince` (and a NEW idempotency key) to continue.\n\n**Cost.** A push charges no credits: it re-queues leads that were already captured and enriched, and re-enriches none, so a backfill or replay of any size is free.\n\nSaving or changing a webhook URL through this API does NOT deliver history \u2014 this operation is the only public way to send it.\n\n**UNTRACKED SOURCES CANNOT BE PUSHED.** A source you untracked is not actionable: this returns `404`, the same as a source that was never tracked. Untracking is a soft delete, so its leads are still in the database \u2014 but they are out of every view AND out of every delivery, because a push is the operation that acts on leads that already exist, and sending data the product told you was gone into your CRM is the one outcome deletion has to prevent. The same rule governs this source's webhook and ICP configuration, which is what a push delivers to. KEYWORD searches are the deliberate exception, here as everywhere: stopping a search keeps its leads readable, so pushing them stays available.",
        "parameters": [
          {
            "name": "username",
            "in": "path",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/LinkedInUsername"
            },
            "description": "Public identifier of the tracked company page (not a full URL), e.g. demo-company."
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LeadPushRequest"
              },
              "examples": {
                "allHistory": {
                  "summary": "Every enriched lead captured so far",
                  "value": {
                    "scope": "all",
                    "idempotencyKey": "backfill-2026-09-08"
                  }
                },
                "icpOnly": {
                  "summary": "Only ICP-matching leads, last 30 days",
                  "value": {
                    "scope": "icp",
                    "since": "2026-08-09T00:00:00Z",
                    "idempotencyKey": "icp-sept"
                  }
                },
                "dryRun": {
                  "summary": "Count the cohort without queueing anything",
                  "value": {
                    "scope": "icp",
                    "dryRun": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The push was accepted (or replayed). Delivery happens asynchronously; poll `statusUrl`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LeadPushResult"
                }
              }
            }
          },
          "400": {
            "description": "Invalid scope, window or idempotencyKey \u2014 or the source has no delivery destination configured."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "No such tracked source for this team \u2014 or one you untracked, which is not actionable (its leads are retained but never delivered). A stopped KEYWORD search is the exception and still pushes."
          },
          "409": {
            "description": "`idempotencyKey` was already used for a push with different parameters."
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/keyword/{id}/push": {
      "post": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Push historical leads for a keyword search",
        "operationId": "pushKeywordLeads",
        "description": "Push ALREADY-CAPTURED leads to this source's configured destination \u2014 the historical backfill / replay action that `autoSend: false` (\"deliver only on explicit push\") refers to, and the public form of the dashboard's \"select the leads to push\" panel.\n\n**Historical vs future.** A push selects on `detectedAt` over the half-open window `[since, until)`. When `until` is omitted it is stamped with the instant the push is accepted, so the cohort is closed and reproducible: everything with `detectedAt` before that instant is HISTORICAL and belongs to this push; everything at or after it is FUTURE and belongs to auto-send. A push never races capture.\n\n**ICP vs all.** `scope: \"all\"` selects every eligible lead on the source; `scope: \"icp\"` selects only those with `isIcp: true` \u2014 the same flag `GET /api/v1/leads` returns and the same one the source's ICP rules write. NOTE the interaction with the webhook's own `icpOnly` flag: `icpOnly` filters at DELIVERY time, so an `all` push against an `icpOnly: true` webhook queues everything and delivers only the ICP subset \u2014 the rest are reported as `held` by the status endpoint.\n\n**Eligibility.** Only `enriched` leads are ever selected: the delivered payload is built from enrichment fields and the delivery poller only reads enriched rows, so an un-enriched lead moved to `pending` would never be sent. The response's `notSelected` counts the leads in the same scope and window that were skipped for that reason: `awaitingEnrichment` (still being enriched \u2014 auto-send or a later push delivers them) and `enrichmentFailed` (enrichment ended without data; never deliverable).\n\n**Idempotency.** Supply `idempotencyKey` to make a retry safe: a second push with the same key returns the FIRST push's result with `replayed: true` and queues nothing. The same key with different parameters is a `409`. Without a key every call is a new push (and a re-push of an already-delivered lead is a legitimate replay \u2014 delivery is at-least-once, dedupe on `data.leadId`).\n\n**Progress.** The response carries a `pushId` and `statusUrl`; poll `GET /api/v1/push/{pushId}` for the live status distribution of exactly this push's cohort.\n\n**Size.** At most 2000 leads per push, oldest first. A larger cohort returns `truncated: true` and a `nextSince`; repeat the push with `since: nextSince` (and a NEW idempotency key) to continue.\n\n**Cost.** A push charges no credits: it re-queues leads that were already captured and enriched, and re-enriches none, so a backfill or replay of any size is free.\n\nSaving or changing a webhook URL through this API does NOT deliver history \u2014 this operation is the only public way to send it.\n\n**UNTRACKED SOURCES CANNOT BE PUSHED.** A source you untracked is not actionable: this returns `404`, the same as a source that was never tracked. Untracking is a soft delete, so its leads are still in the database \u2014 but they are out of every view AND out of every delivery, because a push is the operation that acts on leads that already exist, and sending data the product told you was gone into your CRM is the one outcome deletion has to prevent. The same rule governs this source's webhook and ICP configuration, which is what a push delivers to. KEYWORD searches are the deliberate exception, here as everywhere: stopping a search keeps its leads readable, so pushing them stays available.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The keyword search's source id, from GET /api/v1/sources. A keyword search is addressed by id, not by its keyword text."
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LeadPushRequest"
              },
              "examples": {
                "allHistory": {
                  "summary": "Every enriched lead captured so far",
                  "value": {
                    "scope": "all",
                    "idempotencyKey": "backfill-2026-09-08"
                  }
                },
                "icpOnly": {
                  "summary": "Only ICP-matching leads, last 30 days",
                  "value": {
                    "scope": "icp",
                    "since": "2026-08-09T00:00:00Z",
                    "idempotencyKey": "icp-sept"
                  }
                },
                "dryRun": {
                  "summary": "Count the cohort without queueing anything",
                  "value": {
                    "scope": "icp",
                    "dryRun": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The push was accepted (or replayed). Delivery happens asynchronously; poll `statusUrl`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LeadPushResult"
                }
              }
            }
          },
          "400": {
            "description": "Invalid scope, window or idempotencyKey \u2014 or the source has no delivery destination configured."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "No such tracked source for this team \u2014 or one you untracked, which is not actionable (its leads are retained but never delivered). A stopped KEYWORD search is the exception and still pushes."
          },
          "409": {
            "description": "`idempotencyKey` was already used for a push with different parameters."
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/sources/{id}/push": {
      "post": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Push historical leads for any source, by source id",
        "operationId": "pushSourceLeads",
        "description": "THE SAME PUSH, ADDRESSED BY SOURCE ID, FOR EVERY KIND OF SOURCE \u2014 a person, a company page, a keyword search or a TRACKED POST. It is the only push a tracked post has: the other push routes are keyed by a LinkedIn handle or a keyword search's id, and a post's identifier is an activity URN, so its leads \u2014 including the ones captured before its webhook was set \u2014 could not be backfilled or replayed after a failed delivery. Body, selection, idempotency, paging and response are exactly those of the other push routes. For a tracked post it is also what `autoSend: false` (PUT /api/v1/sources/{id}/webhook) holds new leads for.\n\nPush ALREADY-CAPTURED leads to this source's configured destination \u2014 the historical backfill / replay action that `autoSend: false` (\"deliver only on explicit push\") refers to, and the public form of the dashboard's \"select the leads to push\" panel.\n\n**Historical vs future.** A push selects on `detectedAt` over the half-open window `[since, until)`. When `until` is omitted it is stamped with the instant the push is accepted, so the cohort is closed and reproducible: everything with `detectedAt` before that instant is HISTORICAL and belongs to this push; everything at or after it is FUTURE and belongs to auto-send. A push never races capture.\n\n**ICP vs all.** `scope: \"all\"` selects every eligible lead on the source; `scope: \"icp\"` selects only those with `isIcp: true` \u2014 the same flag `GET /api/v1/leads` returns and the same one the source's ICP rules write. NOTE the interaction with the webhook's own `icpOnly` flag: `icpOnly` filters at DELIVERY time, so an `all` push against an `icpOnly: true` webhook queues everything and delivers only the ICP subset \u2014 the rest are reported as `held` by the status endpoint.\n\n**Eligibility.** Only `enriched` leads are ever selected: the delivered payload is built from enrichment fields and the delivery poller only reads enriched rows, so an un-enriched lead moved to `pending` would never be sent. The response's `notSelected` counts the leads in the same scope and window that were skipped for that reason: `awaitingEnrichment` (still being enriched \u2014 auto-send or a later push delivers them) and `enrichmentFailed` (enrichment ended without data; never deliverable).\n\n**Idempotency.** Supply `idempotencyKey` to make a retry safe: a second push with the same key returns the FIRST push's result with `replayed: true` and queues nothing. The same key with different parameters is a `409`. Without a key every call is a new push (and a re-push of an already-delivered lead is a legitimate replay \u2014 delivery is at-least-once, dedupe on `data.leadId`).\n\n**Progress.** The response carries a `pushId` and `statusUrl`; poll `GET /api/v1/push/{pushId}` for the live status distribution of exactly this push's cohort.\n\n**Size.** At most 2000 leads per push, oldest first. A larger cohort returns `truncated: true` and a `nextSince`; repeat the push with `since: nextSince` (and a NEW idempotency key) to continue.\n\n**Cost.** A push charges no credits: it re-queues leads that were already captured and enriched, and re-enriches none, so a backfill or replay of any size is free.\n\nSaving or changing a webhook URL through this API does NOT deliver history \u2014 this operation is the only public way to send it.\n\n**UNTRACKED SOURCES CANNOT BE PUSHED.** A source you untracked is not actionable: this returns `404`, the same as a source that was never tracked. Untracking is a soft delete, so its leads are still in the database \u2014 but they are out of every view AND out of every delivery, because a push is the operation that acts on leads that already exist, and sending data the product told you was gone into your CRM is the one outcome deletion has to prevent. The same rule governs this source's webhook and ICP configuration, which is what a push delivers to. KEYWORD searches are the deliberate exception, here as everywhere: stopping a search keeps its leads readable, so pushing them stays available.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The source id from GET /api/v1/sources, for ANY kind: a person, a company page, a keyword search or a tracked post."
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LeadPushRequest"
              },
              "examples": {
                "allHistory": {
                  "summary": "Every enriched lead captured so far",
                  "value": {
                    "scope": "all",
                    "idempotencyKey": "backfill-2026-09-08"
                  }
                },
                "icpOnly": {
                  "summary": "Only ICP-matching leads, last 30 days",
                  "value": {
                    "scope": "icp",
                    "since": "2026-08-09T00:00:00Z",
                    "idempotencyKey": "icp-sept"
                  }
                },
                "dryRun": {
                  "summary": "Count the cohort without queueing anything",
                  "value": {
                    "scope": "icp",
                    "dryRun": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The push was accepted (or replayed). Delivery happens asynchronously; poll `statusUrl`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LeadPushResult"
                }
              }
            }
          },
          "400": {
            "description": "The path segment is not a source id (a UUID from GET /api/v1/sources), or an invalid scope, window or idempotencyKey \u2014 or the source has no delivery destination configured."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "No such tracked source for this team \u2014 or one you untracked, which is not actionable (its leads are retained but never delivered). A stopped KEYWORD search is the exception and still pushes."
          },
          "409": {
            "description": "`idempotencyKey` was already used for a push with different parameters."
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/push/{pushId}": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Get the progress of a lead push",
        "operationId": "getLeadPush",
        "description": "Live progress for one recorded push, over EXACTLY the leads that push selected.\n\nThis is why a push is recorded at all: `webhookStatus` on a lead is shared by every delivery path (auto-send, the daily sync, an earlier push), so it cannot answer \"how far along is the push I started?\". The counts here are the current status distribution of this push's own cohort.\n\n`delivered` is `sent`, `failed` is a delivery that exhausted its attempts, and `held` is `no_webhook` \u2014 the delivery path looked at the lead and had nothing to send it to, which in practice means an `icpOnly` webhook rejecting a non-ICP lead. `done` is true once nothing is `pending` or `sending`. `status` says where the push stands in one word \u2014 `queued` means none of its leads has been attempted yet \u2014 and `stalled` is true when its leads are still unattempted five minutes (`stalledAfterSeconds`) after the push was accepted: a delay on Cornersight's side, not your endpoint. A healthy push is attempted within about a minute.",
        "parameters": [
          {
            "name": "pushId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The pushId returned by a push operation."
          }
        ],
        "responses": {
          "200": {
            "description": "Current progress for the push.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LeadPushProgress"
                }
              }
            }
          },
          "400": {
            "description": "The path segment is not a pushId."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "No push with that id for this team."
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/sources/{id}/posts": {
      "get": {
        "tags": [
          "Tracked Profiles"
        ],
        "summary": "Read the posts a posts-only watch has seen",
        "operationId": "listSourcePosts",
        "description": "WHAT A POSTS-ONLY TRACKED PROFILE OR KEYWORD SEARCH HAS SEEN, newest first by when WE saw it. A posts-only source delivers each new post as a `post.detected` webhook. \u26a0 POSTS-ONLY SOURCES ONLY \u2014 404 `not_posts_only` otherwise. An engagers source reports captured people as leads; an engagers-mode keyword search also reports post verdicts at GET /api/v1/sources/{id}/kept-posts, which does not include post text. \u26a0 `firstSeenAt` IS WHEN WE SAW IT, NOT WHEN IT WAS POSTED. READING THIS CHARGES NOTHING: the credit was spent only when each new matching post was first recorded. EACH POST CARRIES `postedAt` AND `postedAtTimestamp`, and on a KEYWORD search also what the search said about it when it was found: `author` { `name`, `url`, `headline` }, `commentsCount`, `totalReactionCount` and `contentType`, in the names and types GET /api/v1/profile/{username}/posts uses. A keyword post's `postedAt` is the instant its activity URN encodes \u2014 the keyword search result has no time field \u2014 and keyword posts recorded before the September 2026 worker release that added them serve all of these as null (not back-filled). On a person or company watch `commentsCount`, `totalReactionCount` and `contentType` are what the watch saw when it FIRST RECORDED the post (never refreshed; null on watch posts recorded before the September 2026 worker release that added them, not back-filled), `postedAt` is the instant the post's activity URN encodes (or, for a post keyed by its ugcPost or share id, the instant that id encodes), and `author` is always null \u2014 the watched source is the author. Every key is present on every row; a null is never a zero.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The tracked source's id, as GET /api/v1/sources returns it."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            },
            "description": "Posts to return, 1-200. Defaults to 50. A value outside the range is a 400 naming it."
          }
        ],
        "responses": {
          "200": {
            "description": "The posts this source has seen, newest first by `firstSeenAt`.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "sourceId",
                    "username",
                    "type",
                    "mode",
                    "posts",
                    "total"
                  ],
                  "properties": {
                    "sourceId": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "username": {
                      "type": "string",
                      "description": "The watched handle, company slug, or keyword search expression."
                    },
                    "type": {
                      "type": "string",
                      "enum": [
                        "person",
                        "company",
                        "keyword"
                      ]
                    },
                    "mode": {
                      "type": "string",
                      "enum": [
                        "posts_only"
                      ]
                    },
                    "total": {
                      "type": "integer",
                      "description": "How many rows this response carries."
                    },
                    "posts": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "required": [
                          "urn",
                          "url",
                          "text",
                          "postedAt",
                          "postedAtTimestamp",
                          "totalReactionCount",
                          "commentsCount",
                          "contentType",
                          "author",
                          "firstSeenAt"
                        ],
                        "properties": {
                          "urn": {
                            "type": "string",
                            "example": "urn:li:activity:7496817330904121344"
                          },
                          "url": {
                            "type": "string",
                            "nullable": true
                          },
                          "text": {
                            "type": "string",
                            "nullable": true,
                            "description": "NULL, never missing, when the provider served a post without any \u2014 normal for an image or video post. Read `null`; do not test for the key."
                          },
                          "postedAt": {
                            "type": "string",
                            "format": "date-time",
                            "nullable": true,
                            "description": "When the post was published, served exactly as stored. On a person or company watch it is the instant the post's activity URN encodes (or, for a post keyed by its ugcPost or share id, the instant that id encodes) wherever the URN decodes \u2014 posts stored before the September 2026 worker release included, whose stored time was guessed from a relative label such as \"1d\" \u2014 else the time stored. A KEYWORD search's result has no time field, so a keyword post's is the instant its activity URN encodes (a LinkedIn activity id carries its creation time in its top 41 bits), recorded since the September 2026 worker release that added it; a keyword post recorded before that is NULL here and is not back-filled. Never `firstSeenAt`."
                          },
                          "postedAtTimestamp": {
                            "type": "integer",
                            "nullable": true,
                            "description": "The same instant in epoch milliseconds, as on GET /api/v1/profile/{username}/posts. Null exactly when `postedAt` is."
                          },
                          "totalReactionCount": {
                            "type": "integer",
                            "nullable": true,
                            "description": "Reactions on the post AS THE KEYWORD SEARCH REPORTED THEM when the run found it (the result's `numReactions`) \u2014 a count, not the people, under the same name and type GET /api/v1/profile/{username}/posts uses. Refreshed only when a later run finds the post again (a kept post the budget never reached is re-found); never a later live reading. NULL, not 0, when the search stated no count; NULL on every keyword post found before the worker release that added these fields (September 2026; not back-filled). ON A PERSON OR COMPANY WATCH IT IS WHAT THE WATCH SAW WHEN IT FIRST RECORDED THE POST, never refreshed; NULL on watch posts recorded before the September 2026 worker release that added it (not back-filled).",
                            "example": 108
                          },
                          "commentsCount": {
                            "type": "integer",
                            "nullable": true,
                            "description": "Comments on the post as the keyword search reported them when the run found it (`numComments`) \u2014 a count, not the commenters. 0 is a stated zero, a post nobody had commented on; NULL is a post the search said nothing about, and is never read as 0. NULL on every keyword post found before the worker release that added these fields (September 2026; not back-filled). ON A PERSON OR COMPANY WATCH IT IS WHAT THE WATCH SAW WHEN IT FIRST RECORDED THE POST, never refreshed; NULL on watch posts recorded before the September 2026 worker release that added it (not back-filled).",
                            "example": 16
                          },
                          "contentType": {
                            "type": "string",
                            "nullable": true,
                            "enum": [
                              "VIDEO",
                              "IMAGE",
                              "JOB",
                              "LIVE_VIDEO",
                              "DOCUMENT",
                              "COLLABORATIVE_ARTICLE",
                              null
                            ],
                            "description": "The post's kind, read from the search result's media exactly as GET /api/v1/profile/{username}/posts reads it \u2014 the same vocabulary as the keyword search's own contentType filter. NULL for a text post and for media with no value in this vocabulary (an article, a poll); NULL on every keyword post found before the worker release that added these fields (September 2026; not back-filled). ON A PERSON OR COMPANY WATCH IT IS WHAT THE WATCH SAW WHEN IT FIRST RECORDED THE POST, never refreshed; NULL on watch posts recorded before the September 2026 worker release that added it (not back-filled).",
                            "example": "VIDEO"
                          },
                          "author": {
                            "type": "object",
                            "required": [
                              "name",
                              "url",
                              "headline"
                            ],
                            "description": "WHO WROTE THE POST, from the search result's `actor` \u2014 the field for telling a company page's post from a person's, and a person's headline usually names their company. ALWAYS AN OBJECT with all three keys: a member is null when the search did not state it, and all three are null on every keyword post found before the September 2026 worker release that added it. ON A PERSON OR COMPANY WATCH THIS IS ALWAYS NULL: the watched source is the post's author.",
                            "properties": {
                              "name": {
                                "type": "string",
                                "nullable": true,
                                "description": "The author's display name: a company page's name, or a person's first and last name.",
                                "example": "Bitcoin Magazine"
                              },
                              "url": {
                                "type": "string",
                                "nullable": true,
                                "description": "The author's LinkedIn URL as the search gave it (a /company/ or /in/ URL), or, for a person the search gave no URL for, the /in/ URL of their public handle. Null when it gave neither \u2014 never built from a member URN, and never for a company page.",
                                "example": "https://www.linkedin.com/company/bitcoin-magazine/"
                              },
                              "headline": {
                                "type": "string",
                                "nullable": true,
                                "description": "A person author's LinkedIn headline when the search supplied one. Always null for a company page, which has none.",
                                "example": null
                              }
                            }
                          },
                          "firstSeenAt": {
                            "type": "string",
                            "format": "date-time",
                            "description": "When this system first saw the post \u2014 the instant it was charged for."
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The id is not a UUID, or `limit` is outside 1-200.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "404": {
            "description": "No such source for this team, or the source is not a posts-only watch (`code: \"not_posts_only\"`, whose message names where that kind's posts ARE reported).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/agent": {
      "get": {
        "tags": [
          "Agent"
        ],
        "summary": "Read the Engagement Agent",
        "operationId": "getAgent",
        "description": "The team's Engagement Agent: the profile it targets, its daily credit limit and how that is split (`plan`), whether it is running, its one-off people search and whether that search's people have been added, and every source it set up. `{ \"agent\": null }` when the team has never set one up. Read-only. `agents` lists every agent the team holds; `agentId` picks one.",
        "responses": {
          "200": {
            "description": "The agent, or null.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgentResponse"
                },
                "examples": {
                  "example": {
                    "value": {
                      "agent": {
                        "status": "active",
                        "website": "acme.com",
                        "profile": {
                          "summary": "Sales engagement software for B2B teams.",
                          "titles": [
                            "VP Sales",
                            "Head of Growth"
                          ],
                          "industries": [
                            "Software Development"
                          ],
                          "companySizes": [
                            "51-200",
                            "201-500"
                          ],
                          "countries": [
                            "United Kingdom"
                          ],
                          "topics": [
                            "cold email",
                            "AI SDR"
                          ],
                          "competitors": [
                            {
                              "name": "Outreach",
                              "domain": "outreach.io"
                            }
                          ]
                        },
                        "dailyCredits": 1000,
                        "plan": {
                          "perTopic": 250,
                          "people": 20,
                          "total": 1000
                        },
                        "launchedAt": "2026-10-06T09:00:00.000Z",
                        "discoverRun": "8a1c0b2e-4f3d-4c55-9e0a-2b7d1f6c9a10",
                        "peopleAdded": true,
                        "sources": [
                          {
                            "id": "5c0e6a7b-1d2f-4e3a-8b9c-0d1e2f3a4b5c",
                            "type": "keyword",
                            "label": "cold email",
                            "active": true
                          },
                          {
                            "id": "9f8e7d6c-5b4a-4321-8fed-cba987654321",
                            "type": "person",
                            "label": "Jane Doe",
                            "active": true
                          }
                        ]
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "502": {
            "description": "The agent could not be read or saved; nothing was changed. Retry shortly.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgentError"
                },
                "examples": {
                  "example": {
                    "value": {
                      "error": "Could not read your agent; nothing was changed. Retry shortly."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "agentId is not a UUID.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgentError"
                }
              }
            }
          },
          "404": {
            "description": "Code `agent_not_found`: no agent with that `agentId`, or the team has no agent yet. Create one with PUT /api/v1/agent. Without `agentId` there is no 404: the answer is `{ agent: null, agents: [] }`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgentError"
                }
              }
            }
          }
        },
        "parameters": [
          {
            "name": "agentId",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Which agent: a team may hold several, one per website (GET /api/v1/agent lists them in `agents`). Omit for the team's first agent."
          }
        ]
      },
      "put": {
        "tags": [
          "Agent"
        ],
        "summary": "Create or update the Engagement Agent's profile",
        "operationId": "updateAgent",
        "description": "A PARTIAL update of the website, the profile's parts and the daily limit: only the fields sent change. Creates the agent as a draft when the team has none. Starts nothing and charges nothing: a running agent keeps its sources and caps until POST /api/v1/agent/start runs again. The body is flat: `topics`, `titles` and the rest sit beside `website` and `dailyCredits`, and come back under `agent.profile`. Drafting a profile from the website is a dashboard-only step, so callers set the parts themselves. Also sets the agent's webhook, and `create: true` adds another agent.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AgentUpdateRequest"
              },
              "examples": {
                "example": {
                  "value": {
                    "website": "acme.com",
                    "titles": [
                      "VP Sales",
                      "Head of Growth"
                    ],
                    "industries": [
                      "Software Development"
                    ],
                    "companySizes": [
                      "51-200",
                      "201-500"
                    ],
                    "countries": [
                      "United Kingdom"
                    ],
                    "topics": [
                      "cold email",
                      "AI SDR"
                    ],
                    "dailyCredits": 1000
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The saved agent.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgentResponse"
                }
              }
            }
          },
          "400": {
            "description": "A field is invalid (named in the message), an unknown field was sent (`unknown_field`), or nothing was sent. The webhook is refused when its url is not a public http(s) URL, when it has a key other than url, autoSend, filters (and the read-only since, which is ignored), or when a filter row's operator or value is not one its column takes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgentError"
                },
                "examples": {
                  "example": {
                    "value": {
                      "error": "topics takes at most 20 entries."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "502": {
            "description": "The agent could not be read or saved; nothing was changed. Retry shortly.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgentError"
                },
                "examples": {
                  "example": {
                    "value": {
                      "error": "Could not read your agent; nothing was changed. Retry shortly."
                    }
                  }
                }
              }
            }
          },
          "503": {
            "description": "Code `agent_unavailable`: this server's database does not have the agent yet.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgentError"
                },
                "examples": {
                  "example": {
                    "value": {
                      "error": "The agent is not available on this server yet.",
                      "code": "agent_unavailable"
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "Code `trial_agent_limit`: a free trial has one agent.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgentError"
                }
              }
            }
          },
          "404": {
            "description": "Code `agent_not_found`: no agent with that `agentId`, or the team has no agent yet. Create one with PUT /api/v1/agent.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgentError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/agent/start": {
      "post": {
        "tags": [
          "Agent"
        ],
        "summary": "Start the Engagement Agent",
        "operationId": "startAgent",
        "description": "The dashboard's launch, server side. It first STOPS every source the agent set up before (the soft untrack DELETE /api/v1/keyword/{id} and /profile/{username} run, so their leads are kept), then creates one daily keyword search per topic, named `Agent: <topic>`, past week, capturing the people who engage, each capped at `plan.perTopic` (a live search the team already has on a topic is reused and never re-capped), and starts a one-off people search: a Discover run on the first 10 topics for the most engaged people posting about them, capped at `plan.people`. WHEN THAT SEARCH FINISHES, THE WORKER ADDS ITS PEOPLE as watched profiles (25 credits a sync each, a 5-post first sync); `agent.peopleAdded` turns true then. THE AGENT RECURS: every topic search runs daily and every watched person syncs daily until POST /api/v1/agent/stop, so `plan.total` is a standing daily commitment. Billing: one credit per new person any of its sources captures, whether or not they match the profile. Without `\"confirmSpend\": true` the call is refused 409 `spend_confirmation_required` with the figures, and nothing is stopped or created. Each topic search goes through POST /api/v1/keyword/track's own checks (balance, trial limits); one that is refused is listed in `launch.failed` and the rest start. On a free trial the agent checks 1,000 people in all on its own allowance: its topic searches and people never use the trial's own keyword or profile slots.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AgentStartRequest"
              },
              "examples": {
                "example": {
                  "value": {
                    "dailyCredits": 1000,
                    "confirmSpend": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Started: the agent and what this start did.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "agent",
                    "launch"
                  ],
                  "properties": {
                    "agent": {
                      "$ref": "#/components/schemas/Agent"
                    },
                    "launch": {
                      "$ref": "#/components/schemas/AgentLaunchReport"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "An invalid field, no topics (`agent_no_topics`), or no daily limit here or on the profile (`agent_daily_credits_required`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgentError"
                },
                "examples": {
                  "example": {
                    "value": {
                      "error": "Add at least one topic before starting the agent: PUT /api/v1/agent with \"topics\".",
                      "code": "agent_no_topics"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Code `agent_not_started`: nothing could be created, so the agent is left as a draft and `launch.failed` says why each part was refused (for example, out of credits). It comes with the status of the first refusal (400, 402, 403, 409 or 429), or 502 when a create failed outright. Earlier agent sources were already stopped.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/AgentError"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "launch": {
                          "$ref": "#/components/schemas/AgentLaunchReport"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "description": "Code `agent_not_found`: the team has no agent yet. Create one with PUT /api/v1/agent.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgentError"
                },
                "examples": {
                  "example": {
                    "value": {
                      "error": "This team has no agent yet. Create one with PUT /api/v1/agent, giving at least one topic.",
                      "code": "agent_not_found"
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "Code `spend_confirmation_required`. Nothing was stopped or created. Say `estimatedDailyMax`, that it recurs daily, and `discoverCost` to the person, then re-send with `\"confirmSpend\": true`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgentSpendConfirmation"
                },
                "examples": {
                  "example": {
                    "value": {
                      "error": "Starting this agent can spend up to 1000 credits a day, every day until it is stopped: 2 daily topic searches at up to 250 each, and up to 20 watched people at up to 25 each. Finding those people is a one-off of up to 20 credits. Re-send with \"confirmSpend\": true to start it.",
                      "code": "spend_confirmation_required",
                      "estimatedDailyMax": 1000,
                      "dailyCredits": 1000,
                      "plan": {
                        "perTopic": 250,
                        "people": 20,
                        "total": 1000
                      },
                      "discoverCost": 20,
                      "topics": [
                        "cold email",
                        "AI SDR"
                      ]
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "502": {
            "description": "The agent could not be read or saved. When the save fails after the searches were created, they are running: GET /api/v1/agent shows the state, and starting again reuses them rather than adding more.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgentError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/agent/stop": {
      "post": {
        "tags": [
          "Agent"
        ],
        "summary": "Stop the Engagement Agent",
        "operationId": "stopAgent",
        "description": "Untracks every active source the agent set up (the same soft untrack the DELETE routes run), so none of them runs or charges again, and sets the agent back to `draft`. The leads they captured are kept and GET /api/v1/agent/leads still serves them. A people search already running finishes, but its people are not added once the agent is stopped. A trial team's own sources cannot be untracked and are listed in `stop.notStopped`. Takes only an optional `agentId`.",
        "responses": {
          "200": {
            "description": "Stopped: the agent and what this call stopped.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "agent",
                    "stop"
                  ],
                  "properties": {
                    "agent": {
                      "$ref": "#/components/schemas/Agent"
                    },
                    "stop": {
                      "$ref": "#/components/schemas/AgentStopReport"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "A body field other than agentId was sent.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgentError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Code `agent_not_found`: the team has no agent yet. Create one with PUT /api/v1/agent.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgentError"
                },
                "examples": {
                  "example": {
                    "value": {
                      "error": "This team has no agent yet. Create one with PUT /api/v1/agent, giving at least one topic.",
                      "code": "agent_not_found"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "502": {
            "description": "The agent could not be read or saved. Sources already stopped stay stopped: GET /api/v1/agent shows the state, and stopping again is safe.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgentError"
                }
              }
            }
          }
        },
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AgentStopRequest"
              }
            }
          }
        }
      }
    },
    "/api/v1/agent/leads": {
      "get": {
        "tags": [
          "Agent"
        ],
        "summary": "List the Engagement Agent's leads",
        "operationId": "listAgentLeads",
        "description": "Agent Leads: the people the agent's sources captured, one row per person. Agent Leads is RANKED, one row per person: the job title must match (it gates); everything else lowers the ICP % (title 40, country 30, industry 15, company size 15; a part enrichment could not fill earns half; parts left empty are not counted), from 40 to 100. Each person's signal is extra_strong (engaged twice or more), strong (a comment on a post about the topics, a comment on a lead-magnet post, or a 100% ICP match on a post about the topics), medium (any other engagement) or weak (only an engagement with a hiring post or personal news). Ranked by ICP %, then signal, then newest: exactly the dashboard's Agent Leads table, built by the same code. `checked` is everyone the sources captured, which is what the team paid for; `total` is how many show. `firstRun.done` is false while the first run is still going. `filters` narrows the list as the table's Filters button does. Leads of a source the agent later stopped are kept and listed. Reads the newest 5,000 captured leads.",
        "parameters": [
          {
            "name": "agentId",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Which agent: a team may hold several, one per website (GET /api/v1/agent lists them in `agents`). Omit for the team's first agent."
          },
          {
            "name": "filters",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "The Filters rows as a JSON array of AgentFilterRow, e.g. [{\"column\":\"icp\",\"operator\":\"at_least\",\"value\":\"70\"}]."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            },
            "description": "Page size, 1-200."
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            },
            "description": "How many matching leads to skip."
          }
        ],
        "responses": {
          "200": {
            "description": "A page of matching leads.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgentLeadList"
                }
              }
            }
          },
          "400": {
            "description": "An unknown query parameter, a limit/offset out of range, an agentId that is not a UUID, or `filters` that is not a JSON array of rows, names an unknown column, or has an operator or value its column does not take.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgentError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Code `agent_not_found`: the team has no agent yet. Create one with PUT /api/v1/agent.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgentError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "502": {
            "description": "The agent could not be read or saved; nothing was changed. Retry shortly.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgentError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/agent/push": {
      "post": {
        "tags": [
          "Agent"
        ],
        "summary": "Send Agent Leads to the agent's webhook",
        "operationId": "pushAgentLeads",
        "description": "The dashboard's \"Push historic leads\": without `leadIds`, every Agent Lead that passes the webhook's own filters; with them, those leads. Each is marked for delivery and sent as one signed POST, retried like the team's other webhooks, from the agent's sources that carry its URL. A lead already sent or on its way is not sent again. New leads go by themselves while the webhook's autoSend is on. Charges no credits.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AgentPushRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Queued.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgentPushResult"
                }
              }
            }
          },
          "400": {
            "description": "Code `no_webhook`: the agent has no webhook. Also a malformed body: an agentId that is not a UUID, leadIds that is not an array of up to 5,000 lead ids, or an unknown field (`unknown_field`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgentError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Code `agent_not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgentError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "502": {
            "description": "The agent could not be read, or the leads could not be queued. Nothing already queued is undone; pushing again never sends a lead twice.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgentError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/discover": {
      "post": {
        "tags": [
          "Discover"
        ],
        "summary": "Start a Discover run",
        "operationId": "createDiscoverRun",
        "description": "Start a ONE-OFF Discover Influencers run: the people who post about these keywords in the past month and average at least `minEngagement` likes + comments per matching post, at most `maxInfluencers` of them, optionally only in `countries`. Synchronous: answers 201 with the run (`state: \"running\"`) and the worker runs it within moments; most runs finish within a few minutes. Poll GET /api/v1/discover/{id} for its influencers.\n\nSPEND CONFIRMATION. The run costs 1 credit per influencer added, so up to `maxInfluencers` credits, once. Without `\"confirmSpend\": true` the request is refused with 409 `spend_confirmation_required` carrying `estimatedCredits` and NOTHING is created: show that figure to the person, then re-send the identical request with `\"confirmSpend\": true`. `confirmSpend: false` is also a 409; a non-boolean is a 400.\n\nThe keywords are compiled into one OR search exactly as the dashboard compiles them, and the run is the same run the dashboard's Find influencers button creates: past month, sorted by relevance, people only, one credit per person added. See the Discover tag for how engagement, the maximum and the country filter work.\n\nFREE TRIAL. A trial has 2 influencer searches of up to 100 people each: maxInfluencers over 100 is a 400 and a third search is a 403, both with code `trial_profile_limit`, before any spend question. The agent's own people search does not use them.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DiscoverRunRequest"
              },
              "examples": {
                "quote": {
                  "summary": "First call: no confirmSpend, so the 409 quotes the credits",
                  "value": {
                    "keywords": [
                      "claude code",
                      "ai agents"
                    ],
                    "minEngagement": 50,
                    "maxInfluencers": 25,
                    "countries": [
                      "UK"
                    ]
                  }
                },
                "confirmed": {
                  "summary": "Second call: the same body with confirmSpend",
                  "value": {
                    "keywords": [
                      "claude code",
                      "ai agents"
                    ],
                    "minEngagement": 50,
                    "maxInfluencers": 25,
                    "countries": [
                      "UK"
                    ],
                    "confirmSpend": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The run was created and queued. Countries come back cleaned (\"UK\" is \"United Kingdom\").",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DiscoverRun"
                },
                "examples": {
                  "started": {
                    "summary": "A run that has just started",
                    "value": {
                      "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
                      "keywords": [
                        "claude code",
                        "ai agents"
                      ],
                      "expression": "\"claude code\" OR \"ai agents\"",
                      "minEngagement": 50,
                      "maxInfluencers": 25,
                      "countries": [
                        "United Kingdom"
                      ],
                      "datePosted": "PAST_MONTH",
                      "state": "running",
                      "candidates": null,
                      "qualified": null,
                      "found": 0,
                      "countryChecked": null,
                      "countryMatched": null,
                      "createdAt": "2026-10-05T12:00:00.000Z",
                      "finishedAt": null,
                      "stoppedBy": null,
                      "reason": null
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The body is invalid, and nothing was created. One sentence names the problem: keywords missing, not an array of strings, empty, duplicated, more than 10, or holding a quotation mark or bracket; minEngagement or maxInfluencers missing or out of range; countries not an array, more than 20, or an entry with no letters (\"\\\"123\\\" is not a country.\"); confirmSpend not a boolean. An unknown field is a 400 with `code: \"unknown_field\"` naming it. On a free trial, maxInfluencers over 100 is a 400 with `code: \"trial_profile_limit\"`.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string",
                      "enum": [
                        "unknown_field"
                      ]
                    }
                  }
                },
                "examples": {
                  "tooMany": {
                    "summary": "Eleven keywords",
                    "value": {
                      "error": "keywords must hold 1-10 terms; this has 11."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/SubscriptionInactive"
          },
          "409": {
            "description": "Code `spend_confirmation_required`: `confirmSpend` was absent or false. NOTHING WAS CREATED. `estimatedCredits` is the most the run can spend (= maxInfluencers, 1 credit per person added, once). Show it to the person and re-send the identical body with `\"confirmSpend\": true`.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error",
                    "code",
                    "estimatedCredits"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string",
                      "enum": [
                        "spend_confirmation_required"
                      ]
                    },
                    "estimatedCredits": {
                      "type": "integer",
                      "minimum": 1,
                      "maximum": 500
                    }
                  }
                },
                "examples": {
                  "quote": {
                    "summary": "The quote",
                    "value": {
                      "error": "This run can use up to 25 credits: one for each person who meets the bar, charged once, not daily. Nothing was created. Re-send with \"confirmSpend\": true to start it.",
                      "code": "spend_confirmation_required",
                      "estimatedCredits": 25
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      },
      "get": {
        "tags": [
          "Discover"
        ],
        "summary": "List Discover runs",
        "operationId": "listDiscoverRuns",
        "description": "This team's Discover runs, newest first: the 50 most recent, the same list as Your Searches on the dashboard's Discover Influencers page. Deleted runs are not listed. Synchronous and free. `candidates`, `qualified`, `countryChecked` and `countryMatched` are null until a run finishes; `found` is how many influencers it added (and charged for). When `qualified` is greater than `found`, the run stopped at its `maxInfluencers` limit.",
        "responses": {
          "200": {
            "description": "The runs.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DiscoverRunList"
                },
                "examples": {
                  "two": {
                    "summary": "One finished run and one still running",
                    "value": {
                      "runs": [
                        {
                          "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
                          "keywords": [
                            "claude code",
                            "ai agents"
                          ],
                          "expression": "\"claude code\" OR \"ai agents\"",
                          "minEngagement": 50,
                          "maxInfluencers": 25,
                          "countries": [
                            "United Kingdom"
                          ],
                          "datePosted": "PAST_MONTH",
                          "state": "running",
                          "candidates": null,
                          "qualified": null,
                          "found": 0,
                          "countryChecked": null,
                          "countryMatched": null,
                          "createdAt": "2026-10-05T12:00:00.000Z",
                          "finishedAt": null,
                          "stoppedBy": null,
                          "reason": null
                        },
                        {
                          "id": "1b4e28ba-2fa1-41d2-883f-0016d3cca427",
                          "keywords": [
                            "claude code",
                            "ai agents"
                          ],
                          "expression": "\"claude code\" OR \"ai agents\"",
                          "minEngagement": 50,
                          "maxInfluencers": 25,
                          "countries": [
                            "United Kingdom"
                          ],
                          "datePosted": "PAST_MONTH",
                          "state": "done",
                          "candidates": 412,
                          "qualified": 31,
                          "found": 25,
                          "countryChecked": 125,
                          "countryMatched": 25,
                          "createdAt": "2026-10-04T09:30:00.000Z",
                          "finishedAt": "2026-10-04T09:34:12.000Z",
                          "stoppedBy": "exhausted",
                          "reason": null
                        }
                      ],
                      "total": 2
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/SubscriptionInactive"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "502": {
            "description": "The runs could not be read. Retry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/discover/{id}": {
      "get": {
        "tags": [
          "Discover"
        ],
        "summary": "Get a Discover run and its influencers",
        "operationId": "getDiscoverRun",
        "description": "One Discover run and the influencers it added, highest average first: the same people Influencer Leads shows for that search. Synchronous and free. `jobTitle`, `company` and `country` come from each person's Author lead once enrichment has filled them, shortly after capture, and are null until then. `highestEngagement` is likes + comments on the person's best matching post (the post they were captured from); until that post is stored it falls back to their average. `topPostText` is null when the post's text was not stored.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The Discover run's id: the `id` POST /api/v1/discover returned, or one from GET /api/v1/discover."
          }
        ],
        "responses": {
          "200": {
            "description": "The run and its influencers.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DiscoverRunDetail"
                },
                "examples": {
                  "done": {
                    "summary": "A finished run",
                    "value": {
                      "run": {
                        "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
                        "keywords": [
                          "claude code",
                          "ai agents"
                        ],
                        "expression": "\"claude code\" OR \"ai agents\"",
                        "minEngagement": 50,
                        "maxInfluencers": 25,
                        "countries": [
                          "United Kingdom"
                        ],
                        "datePosted": "PAST_MONTH",
                        "state": "done",
                        "candidates": 412,
                        "qualified": 31,
                        "found": 25,
                        "countryChecked": 125,
                        "countryMatched": 25,
                        "createdAt": "2026-10-05T12:00:00.000Z",
                        "finishedAt": "2026-10-05T12:06:41.000Z",
                        "stoppedBy": "exhausted",
                        "reason": null
                      },
                      "influencers": [
                        {
                          "name": "Jane Doe",
                          "linkedinUrl": "https://www.linkedin.com/in/jane-doe",
                          "avatarUrl": "https://media.licdn.com/dms/image/example.jpg",
                          "jobTitle": "Founder",
                          "company": "Acme",
                          "country": "United Kingdom",
                          "highestEngagement": 412,
                          "avgEngagement": 268.5,
                          "postCount": 2,
                          "totalEngagement": 537,
                          "topPostUrl": "https://www.linkedin.com/feed/update/urn:li:activity:7381234567890123456/",
                          "topPostText": "We moved our whole release process onto Claude Code. Here is what changed..."
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The path segment is not a run id (a UUID).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/SubscriptionInactive"
          },
          "404": {
            "description": "No active Discover run with that id for this team: it never existed, was deleted, or is a keyword search rather than a Discover run.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "examples": {
                  "missing": {
                    "summary": "Not found",
                    "value": {
                      "error": "Discover run not found"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      },
      "delete": {
        "tags": [
          "Discover"
        ],
        "summary": "Delete a Discover run",
        "operationId": "deleteDiscoverRun",
        "description": "Delete a Discover run, as Delete on Your Searches does. A SOFT DELETE: the run is deactivated, so it leaves GET /api/v1/discover and its influencers leave GET /api/v1/discover/{id} and Influencer Leads, but the Author leads it already added stay in your leads (GET /api/v1/leads?engagementType=Author) and no credit is refunded. A run still in progress stops at its next checkpoint. Only a Discover run: a keyword search's id is a 404 here (stop those with DELETE /api/v1/keyword/{id}). Synchronous.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The Discover run's id: the `id` POST /api/v1/discover returned, or one from GET /api/v1/discover."
          }
        ],
        "responses": {
          "200": {
            "description": "The run is deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "id",
                    "deleted"
                  ],
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "const": true
                    },
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "deleted": {
                      "type": "boolean",
                      "const": true
                    }
                  }
                },
                "examples": {
                  "deleted": {
                    "summary": "Deleted",
                    "value": {
                      "ok": true,
                      "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
                      "deleted": true
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The path segment is not a run id (a UUID).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The team's subscription is inactive, or this is a trial run: \"Trial searches cannot be deleted. Subscribe to a plan to manage them.\"",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "404": {
            "description": "No active Discover run with that id for this team, including one already deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    }
  },
  "webhooks": {
    "lead.detected": {
      "post": {
        "summary": "A lead was captured and enriched",
        "externalDocs": {
          "description": "Signature contract: X-Cornersight-Timestamp is raw Unix epoch seconds (example 1700000000); sign the exact header text, a period, and the exact raw body. Reject timestamps more than five minutes from your clock. Offline fixture and verifier: /docs#webhook-security.",
          "url": "/docs#webhook-security"
        },
        "description": "Sent to the destination URL(s) configured on a tracked source (dashboard -> Integrations; NOT configurable via this API). ONE POST PER LEAD \u2014 never batched. Fired only once the lead is fully ENRICHED, so the firmographic fields are already populated on arrival. Fires from the initial sync, the daily sync, and a background poller that sweeps enriched-but-undelivered leads, so it is not tied to sync completion.\n\nSIGNED with the same scheme and per-team secret as sync events (and the dashboard's test send), so one verifier covers all of them (reveal the signing secret \u2014 it starts `whsec_` \u2014 in the dashboard's Integrations panel, next to the Test button): X-Cornersight-Signature: sha256=<hex> over an HMAC-SHA256 of `{timestamp}.{raw body}`, alongside X-Cornersight-Timestamp (Unix epoch seconds, e.g. 1700000000) / -Event / -Delivery. Verify against the RAW body before parsing (re-serializing JSON can reorder keys and will fail verification) and reject old timestamps; the timestamp is inside the signed material, so a captured body cannot be replayed under a fresh header. X-Cornersight-Delivery is unique per ATTEMPT \u2014 deduplicate on data.leadId, not that header. RETRIED BRIEFLY: a transport error, 408, 429 or 5xx is retried within the same sweep \u2014 3 attempts in all, about 1s and then 4s apart; a 4xx or a redirect is not retried. After the third attempt the lead is marked failed and stays undelivered until it is re-pushed (dashboard, or POST /api/v1/{profile|company}/{username}/push or /api/v1/keyword/{id}/push).\n\nDelivery is oldest-first, ~100ms apart, batches of 100 (up to 1000 per source per sweep), 10s timeout. Every source with queued leads gets a time slice on every sweep, so the first attempt for a newly queued lead \u2014 an explicit push included \u2014 normally lands within about a minute. Ordering is best-effort. Delivery is at-least-once \u2014 deduplicate on leadId.\n\nBACKFILL: leads captured before a webhook existed are queued, not discarded, and deliver automatically once a URL is saved (zero enriching credits). The dashboard can also re-push already-enriched leads on demand, scoped to all / ICP-only / a selection, including ones that already delivered or failed.",
        "tags": [
          "Webhooks"
        ],
        "requestBody": {
          "required": true,
          "description": "Lead delivery payload.",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "event": {
                    "type": "string",
                    "const": "lead.detected"
                  },
                  "timestamp": {
                    "type": "string",
                    "description": "ISO-8601 delivery time."
                  },
                  "data": {
                    "type": "object",
                    "properties": {
                      "leadId": {
                        "type": "string",
                        "description": "Unique id of the lead. Use as the idempotency/deduplication key \u2014 delivery is at-least-once and leads can be re-pushed."
                      },
                      "engagementType": {
                        "type": "string",
                        "enum": [
                          "Like",
                          "Comment",
                          "Author"
                        ],
                        "description": "How the person engaged with the post: `Like`, `Comment`, or `Author` — the person who WROTE a post a keyword search kept, captured when that search has `capturePostAuthors` on. An Author lead is charged like an engager (one credit per new person for the search, repeats free), and the same person can hold an Author row and a Like or Comment row on one post: two rows, one credit."
                      },
                      "linkedinUsername": {
                        "type": "string",
                        "nullable": true,
                        "description": "The engager's LinkedIn handle, taken VERBATIM from the lead row: this payload is not passed through the identity presenter that GET /api/v1/leads uses. For a lead whose stored identity key is a member URN, this field IS that URN (`ACoAAB1_DY0B-SeJU30N`) rather than a handle, and `linkedinUrl` is built from it, so it does not resolve as a public profile. Test for the `ACo` prefix before writing this value into a CRM as a handle \u2014 a delivery is one-shot, so a URN written as a handle is a row you will not get a second chance to correct. `linkedinUrn` beside it is the stored member URN, also sent as stored, and it does not empty this field: when the two are equal, the lead has no known handle. Read the lead back from GET /api/v1/leads, whose `linkedinUsername`/`linkedinUrn` split says plainly which of the two you have."
                      },
                      "linkedinUrl": {
                        "type": "string",
                        "nullable": true,
                        "description": "The engager's LinkedIn profile URL."
                      },
                      "linkedinUrn": {
                        "type": "string",
                        "nullable": true,
                        "description": "The engager's LinkedIn member URN (`ACoAA...`), sent as stored on the lead row, or null when none was captured (older leads may have none). It is the stable identity: compare it with `linkedinUsername`, which holds this same URN when the lead has no known handle."
                      },
                      "avatarUrl": {
                        "type": "string",
                        "nullable": true,
                        "description": "The engager's profile photo URL, or null when none is stored."
                      },
                      "name": {
                        "type": "string",
                        "nullable": true,
                        "description": "Full name."
                      },
                      "jobTitle": {
                        "type": "string",
                        "nullable": true,
                        "description": "Job title (from enrichment)."
                      },
                      "company": {
                        "type": "string",
                        "nullable": true,
                        "description": "Company name (from enrichment)."
                      },
                      "companyName": {
                        "type": "string",
                        "nullable": true,
                        "description": "Company name; an alias of `company` with the same value, kept for parity with GET /api/v1/leads."
                      },
                      "companyDomain": {
                        "type": "string",
                        "nullable": true,
                        "description": "Company website domain (from enrichment)."
                      },
                      "companyUrl": {
                        "type": "string",
                        "nullable": true,
                        "description": "companyUrl is the company's own website, from the website field of its company record, and never a LinkedIn URL; companyLinkedinUrl is its LinkedIn company page. Null when the company record has no website. companyDomain is the same website's hostname. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits."
                      },
                      "companyLinkedinUrl": {
                        "type": "string",
                        "nullable": true,
                        "description": "The employer's LinkedIn company page, read from the person's current position (the company record fills it only when that is missing), or null. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits."
                      },
                      "companyIndustry": {
                        "type": "string",
                        "nullable": true,
                        "description": "The employer's industry, or null. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits."
                      },
                      "companyEmployeeCount": {
                        "type": "integer",
                        "nullable": true,
                        "description": "The employer's reported total employee count, or null (never an invented zero). Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits. companyStaffRange is the LinkedIn size bucket and companyEmployeeCount is the reported total, so the two can disagree."
                      },
                      "companyStaffRange": {
                        "type": "string",
                        "nullable": true,
                        "description": "The employer's LinkedIn size bucket, for example \"51-200\", or null. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits. companyStaffRange is the LinkedIn size bucket and companyEmployeeCount is the reported total, so the two can disagree."
                      },
                      "companyDescription": {
                        "type": "string",
                        "nullable": true,
                        "description": "The employer's description (its tagline when it has no description), or null. companyDescription and companyLocation (headquarters) come from the company record, resolved once per company and cached for 6 months, at no enriching-credit cost."
                      },
                      "companyLocation": {
                        "type": "string",
                        "nullable": true,
                        "description": "The employer's headquarters location, as City, Region, Country, or null. companyDescription and companyLocation (headquarters) come from the company record, resolved once per company and cached for 6 months, at no enriching-credit cost."
                      },
                      "country": {
                        "type": "string",
                        "nullable": true,
                        "description": "Country (from enrichment)."
                      },
                      "commentText": {
                        "type": "string",
                        "nullable": true,
                        "description": "Comment text. Always null for Likes."
                      },
                      "commentPostedAt": {
                        "type": "string",
                        "format": "date-time",
                        "nullable": true,
                        "description": "When the COMMENT itself was posted, ISO 8601 - the comment's time, not the post's. Always null for Likes. On a Comment lead it is the data provider's comment time when one was sent, otherwise the time the comment's own LinkedIn ID encodes; null only when neither was available, which can be the case on Comment leads captured before comment times were decoded."
                      },
                      "postUrl": {
                        "type": "string",
                        "description": "URL of the post they engaged with."
                      },
                      "postText": {
                        "type": "string",
                        "nullable": true,
                        "description": "Text of that post; null if it has none."
                      },
                      "postPostedAt": {
                        "type": "string",
                        "format": "date-time",
                        "nullable": true,
                        "description": "When the POST this lead engaged with was published, ISO 8601 - the post's time, not the comment's (that is commentPostedAt). The same value GET /api/v1/leads returns as postPostedAt; null when the post's time is unknown."
                      },
                      "trackedProfile": {
                        "type": "string",
                        "description": "Username of the tracked source that captured this lead."
                      },
                      "isIcp": {
                        "type": "boolean",
                        "description": "Whether the lead matches the source's ICP filter."
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx as soon as the payload is durably accepted; process asynchronously afterwards."
          }
        }
      }
    },
    "sync.completed": {
      "post": {
        "summary": "A capture run finished (or failed)",
        "externalDocs": {
          "description": "Signature contract: X-Cornersight-Timestamp is raw Unix epoch seconds (example 1700000000); sign the exact header text, a period, and the exact raw body. Reject timestamps more than five minutes from your clock. Offline fixture and verifier: /docs#webhook-security.",
          "url": "/docs#webhook-security"
        },
        "description": "Sent when a tracked source's capture run reaches a terminal state, carrying the same counts as the sync status endpoint. OFF by default (opt in per source with `syncEvents`). Unlike lead.detected this event IS signed and IS retried.\n\nWHICH SOURCES EMIT IT: tracked personal profiles, tracked company pages, and KEYWORD SEARCHES. A keyword sweep writes a sync run exactly as a profile sync does, so its event carries `profileType: keyword`, the search's terms as `username`, and the same state / isFinal / enrichment contract (a keyword search is addressed by SOURCE ID everywhere, so `username` is a label \u2014 `syncId` is what identifies the run). TRACKED POSTS DO NOT: no lifecycle event is sent for a post, because there is no honest `profileType` to send for one. A post's webhook IS configured (PUT /api/v1/sources/{id}/webhook, where `webhookUrl`, `icpOnly` and `autoSend` apply to its `lead.detected`), and `syncEvents: true` on it is a 400 with `code: \"not_supported_for_post\"` rather than a setting saved and ignored.\n\nDELIVERY LATENCY: the event is written to a durable outbox the moment the run reaches its terminal state, and a poller drains that outbox roughly every 10 seconds, so the FIRST attempt normally lands within 10\u201320 seconds of the run ending (10s request timeout). If nothing has arrived about a minute after a run finished, it was not lost in transit \u2014 read `lifecycleEvent` on that source's sync status endpoint, which says whether an event was attempted, how many attempts it has had, and what your endpoint answered.\n\nSigned with HMAC-SHA256 over `{timestamp}.{raw body}` using your team's webhook secret, sent as `X-Cornersight-Signature: sha256=<hex>` alongside X-Cornersight-Timestamp (Unix epoch seconds, e.g. 1700000000) / -Event / -Delivery. Verify against the RAW body before parsing and reject old timestamps to prevent replay.\n\nRetried with exponential backoff (~30s, 2m, 8m, 32m, 2h, 8.5h \u2014 6 attempts) for transport errors, 429 and 5xx only; a 4xx is treated as a rejection and not retried.",
        "tags": [
          "Webhooks"
        ],
        "requestBody": {
          "required": true,
          "description": "Sync lifecycle payload.",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "event": {
                    "type": "string",
                    "enum": [
                      "sync.completed",
                      "sync.failed"
                    ]
                  },
                  "timestamp": {
                    "type": "string",
                    "description": "ISO-8601 delivery time."
                  },
                  "data": {
                    "type": "object",
                    "properties": {
                      "username": {
                        "type": "string",
                        "description": "The tracked source: a LinkedIn handle for a person or company page, and the search TERMS for a keyword search \u2014 the same value GET /api/v1/sources reports as `username`. A keyword search is addressed by source id, so this is a label, never an identifier."
                      },
                      "profileType": {
                        "type": "string",
                        "enum": [
                          "person",
                          "company",
                          "keyword"
                        ],
                        "description": "Which kind of source ran. A tracked post never appears here: its lifecycle events are withheld rather than mislabelled."
                      },
                      "syncId": {
                        "type": "string",
                        "description": "Id of the capture run."
                      },
                      "state": {
                        "type": "string",
                        "description": "Terminal sync state."
                      },
                      "completedAt": {
                        "type": "string",
                        "nullable": true,
                        "description": "When the run finished."
                      },
                      "progress": {
                        "type": "object",
                        "description": "Same counts as the sync status endpoint: postsCollected, engagementsCaptured, leadsTotal, leadsEnriched."
                      },
                      "capture": {
                        "type": "object",
                        "description": "WHY THE CAPTURE ENDED AND WHAT IT COST, under the same key and the same names GET /api/v1/{profile,company}/{username}/sync serves them \u2014 so a subscriber that was told to poll and a subscriber that is called read one path, not two. `stoppedBy` is `credits` (the source's own `creditCapPerSync` stopped the run, so there was more to collect), `exhausted` (it collected everything it found), `capture_empty` (it collected posts and captured NOBODY from them \u2014 a CAPTURE failure, never a quiet week) or `provider_limit` (the data provider stopped serving a post's reactions with a page or more still declared \u2014 about 1,100 people on a large post, measured 29 September 2026), and `null` for a run that FAILED, for one an untrack abandoned, and before the first run finishes. `creditsSpent` is what the ledger has charged this run SO FAR \u2014 at capture end an engagers run's leads are usually still queued for enrichment, which is where they are charged, so it is often `0` here beside `isFinal: false`; the sync endpoint carries the settled figure. `leadRowsCaptured` is the lead-row count `creditsSpent` used to be. Both are present on a failed run too.\n\n\u26a0 PRESENT ON A PERSON/COMPANY SYNC, ABSENT ON A KEYWORD SWEEP, which is the same split `stoppedBy` and `progress.creditsSpent` below already draw. A sweep answers both questions in fields published first and in a wider vocabulary (`budget`, `team_cap`, `lead_cap`, `ai_error`) that this narrower one has no words for, so ABSENT here means \"read it where this kind reports it\", never \"it was free\".",
                        "properties": {
                          "stoppedBy": {
                            "type": "string",
                            "nullable": true,
                            "enum": [
                              "exhausted",
                              "credits",
                              "capture_empty",
                              "provider_limit",
                              null
                            ]
                          },
                          "creditsSpent": {
                            "type": "integer",
                            "nullable": true
                          },
                          "leadRowsCaptured": {
                            "type": "integer",
                            "nullable": true
                          }
                        }
                      },
                      "error": {
                        "type": "string",
                        "nullable": true,
                        "description": "Failure reason; null on sync.completed. Owned, sanitised text \u2014 never the data provider's own response body."
                      },
                      "stoppedBy": {
                        "type": "string",
                        "nullable": true,
                        "enum": [
                          "budget",
                          "credits",
                          "exhausted",
                          "team_cap",
                          "lead_cap",
                          "error",
                          "ai_error",
                          "capture_empty",
                          "post_limit"
                        ],
                        "description": "WHY THE RUN ENDED, on the event a subscriber already receives. KEYWORD SOURCES ONLY \u2014 the field is ABSENT, not null, for a person or company sync, which has no stop condition of this kind. On `sync.completed` it is one of `credits` (the search's own `creditCap` was reached \u2014 the real spend bound), `post_limit` (a `posts_only` search bought its `postsPerSync` posts for the day), `exhausted` (the provider walk ended without a capture cap; `lastRun.providerPageLimitReached: true` proves the page bound fired; false only says it did not, because an all-seen RELEVANCE page can also stop before later unseen posts), `team_cap` (the TEAM's daily keyword ceiling bound, which is about what the team's OTHER searches spent today) or `lead_cap` (the source's own lead ceiling); `budget` is declared for older runs and no longer sent. On `sync.failed` it is `error`, `ai_error` or `capture_empty` (the sweep harvested real posts and every engager fetch answered and returned NOBODY \u2014 a CAPTURE failure, never a narrow search; a run that harvested nothing is a clean `exhausted` instead), or null for a run that threw before it reached its loop \u2014 a stop condition is never invented for a run that had none.\n\n\u26a0 THIS IS THE KEYWORD-SWEEP VOCABULARY, NOT THE SYNC ROUTE'S. GET /api/v1/sources/{id}/sync (and its per-kind siblings) also serve a top-level `stoppedBy`, and the two value sets are DISJOINT: that one answers \"did something cut this run short\" for any kind and holds `credit_cap` or `untracked`. A subscriber who learned `credit_cap` there will not find it here; the same ending is spelled `credits`. This field is the same list GET /api/v1/sources reports as `lastRun.stoppedBy`."
                      },
                      "reason": {
                        "type": "string",
                        "nullable": true,
                        "description": "The run's own sentence for that ending, passed through UNTOUCHED \u2014 the string the dashboard row shows (\"stopped at this team's daily keyword credit ceiling (500 of 500 credits today)\"). Keyword sources only, and nullable on any run that recorded none.\n\n\u26a0 ALWAYS NULL ON `sync.failed`, AND THAT IS A DISCLOSURE BOUNDARY RATHER THAN AN OMISSION. A failing run's reason is `search failed: ${message}`, and that message can carry our data provider's entire response envelope. `error` beside it is the SANITISED field \u2014 owned text, through the same filter the sync endpoint has served `error` with since it shipped \u2014 and a webhook body is no less public than a GET response. So the unsanitised string is published for the endings whose reasons are our own copy built from our own numbers (the caps, the budget, the exhausted sweep) and never for the ones a provider can reach. READ `error` ON A FAILURE; READ `reason` ON A COMPLETION."
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx as soon as the payload is durably accepted; process asynchronously afterwards."
          }
        }
      }
    },
    "spend.cap_reached": {
      "post": {
        "summary": "A search reached its own credit cap",
        "tags": [
          "Webhooks"
        ],
        "description": "ONE SEARCH reached ITS OWN `creditCap` and the sweep stopped there. Not a failure: `sync.completed` still fires for the same run, with `error: null` and `stoppedBy: \"credits\"`. This event exists so that \"tell me when a search of mine runs out\" is a ROUTING RULE on your side rather than a filter you have to write over every lifecycle event.\n\nONCE PER SOURCE PER UTC DAY. A cap that is worth setting is reached on most sweeps \u2014 that is the cap working, not an incident \u2014 so the event is deduped to one per source per calendar day (UTC), claimed atomically, and the claim FAILS CLOSED: if it cannot be taken, nothing is sent. You do not need to deduplicate this stream yourself. Five searches reaching their own caps on one day are five events, because they are five facts about five limits.\n\nDELIVERED TO THAT SOURCE'S WEBHOOK ONLY. It is a fact about one search's own setting; the team's other integrations are not told about it. Same opt-in (`syncEvents`), same signature, same outbox and same retry schedule as the sync events \u2014 nothing new to configure.\n\nKEYWORD SOURCES ONLY: see `profileType`.",
        "requestBody": {
          "required": true,
          "description": "Spend event payload.",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "event": {
                    "type": "string",
                    "enum": [
                      "spend.cap_reached"
                    ]
                  },
                  "timestamp": {
                    "type": "string",
                    "description": "ISO-8601 delivery time."
                  },
                  "data": {
                    "type": "object",
                    "properties": {
                      "sourceId": {
                        "type": "string",
                        "format": "uuid",
                        "description": "WHICH SOURCE this is about, by the id every public surface addresses a source with \u2014 the `id` GET /api/v1/sources reports. Key your own records on this, never on `username`: a keyword search's username is its TERMS, which are editable and are not unique across a team."
                      },
                      "username": {
                        "type": "string",
                        "description": "The source's label \u2014 for a keyword search, its terms. A label, never an identifier."
                      },
                      "profileType": {
                        "type": "string",
                        "enum": [
                          "keyword"
                        ],
                        "description": "ALWAYS `keyword`, and deliberately not widened. A person or company sweep is charged per lead ENRICHED, minutes to hours after its capture ends, so it has no per-run spend at the moment a run finishes and no cap to reach \u2014 those kinds emit no spend event at all."
                      },
                      "syncId": {
                        "type": "string",
                        "nullable": true,
                        "description": "The run this is about \u2014 the same sync id `sync.completed` carries for the same run."
                      },
                      "cap": {
                        "type": "string",
                        "enum": [
                          "search_credit_cap",
                          "team_daily_ceiling"
                        ],
                        "description": "WHICH limit was reached. `search_credit_cap` is the search's OWN `creditCap`, set by whoever made it. `team_daily_ceiling` is the team-wide daily ceiling (an admin's setting, in the dashboard under Settings), reached because of what the team's OTHER searches spent today. Telling a subscriber \"credits\" about the second sends them to raise a cap that was never binding."
                      },
                      "scope": {
                        "type": "string",
                        "enum": [
                          "search",
                          "team"
                        ],
                        "description": "Which thing the cap belongs to, so you can route without matching on `cap`'s spelling."
                      },
                      "capCredits": {
                        "type": "integer",
                        "description": "The cap's value in credits \u2014 the number that was reached."
                      },
                      "creditsSpent": {
                        "type": "integer",
                        "description": "What THIS RUN spent. 0 is a real value and means the run was SKIPPED: the day's ceiling was already spent before it started, so it never called the provider."
                      },
                      "teamSpentToday": {
                        "type": "integer",
                        "nullable": true,
                        "description": "The team's keyword spend TODAY (UTC), INCLUDING this run \u2014 the figure a ceiling is measured against, and the same number GET /api/v1/credits reports as `spentToday`. `null` means NO TEAM CEILING was in force for this run, never zero."
                      },
                      "skipped": {
                        "type": "boolean",
                        "description": "True when the run never started because the day's ceiling was already spent; false when it ran and was stopped at the cap part-way."
                      },
                      "reachedAt": {
                        "type": "string",
                        "format": "date-time",
                        "description": "When the cap was reached, from the run's own clock."
                      },
                      "reason": {
                        "type": "string",
                        "nullable": true,
                        "description": "The run's own sentence for the ending, passed through untouched. Safe to display: these events fire only for the two CAP endings, whose reasons are our own copy built from our own numbers \u2014 no provider text can reach this field, because no provider-failure ending emits a spend event."
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx as soon as the payload is durably accepted; process asynchronously afterwards."
          }
        }
      }
    },
    "spend.ceiling_reached": {
      "post": {
        "summary": "The team's daily keyword ceiling bound",
        "tags": [
          "Webhooks"
        ],
        "description": "THE TEAM'S DAILY KEYWORD CREDIT CEILING bound, and the day's remaining searches are stopped or skipped. A DIFFERENT CONTROL from `spend.cap_reached`, with a different owner (an admin, in Settings), a different scope (the whole team) and a different audience \u2014 which is why it is a different event name rather than a field on one. A search can hit it having spent a fraction of its own cap, and a search that is merely SKIPPED never reaches a cap of its own at all.\n\nONCE PER TEAM PER UTC DAY, FANNED OUT TO EVERY KEYWORD SOURCE with lifecycle events on and a webhook URL set (paused sources included; untracked ones excluded). Whichever run happens to cross the line is an accident of scheduling order, so the event goes to everyone the ceiling affects rather than only to the search that noticed \u2014 otherwise an integration watching a SKIPPED search would hear nothing at all.\n\n\u26a0 `sourceId` AND `username` NAME THE SOURCE WHOSE RUN TRIPPED THE CEILING, and they are the SAME in every copy \u2014 never the recipient. One fact delivered to many endpoints, not many personalised events: rewriting the source per recipient would tell nineteen subscribers that their own search hit a ceiling it never touched. The recipient already knows who it is.\n\nThe ceiling and the day's spend are readable at any time from GET /api/v1/credits (`dailyCeiling`, `dailyCeilingMode`, `spentToday`); it resets at midnight UTC. Same opt-in, signature, outbox and retry schedule as the sync events.",
        "requestBody": {
          "required": true,
          "description": "Spend event payload.",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "event": {
                    "type": "string",
                    "enum": [
                      "spend.ceiling_reached"
                    ]
                  },
                  "timestamp": {
                    "type": "string",
                    "description": "ISO-8601 delivery time."
                  },
                  "data": {
                    "type": "object",
                    "properties": {
                      "sourceId": {
                        "type": "string",
                        "format": "uuid",
                        "description": "WHICH SOURCE this is about, by the id every public surface addresses a source with \u2014 the `id` GET /api/v1/sources reports. Key your own records on this, never on `username`: a keyword search's username is its TERMS, which are editable and are not unique across a team."
                      },
                      "username": {
                        "type": "string",
                        "description": "The source's label \u2014 for a keyword search, its terms. A label, never an identifier."
                      },
                      "profileType": {
                        "type": "string",
                        "enum": [
                          "keyword"
                        ],
                        "description": "ALWAYS `keyword`, and deliberately not widened. A person or company sweep is charged per lead ENRICHED, minutes to hours after its capture ends, so it has no per-run spend at the moment a run finishes and no cap to reach \u2014 those kinds emit no spend event at all."
                      },
                      "syncId": {
                        "type": "string",
                        "nullable": true,
                        "description": "The run this is about \u2014 the same sync id `sync.completed` carries for the same run."
                      },
                      "cap": {
                        "type": "string",
                        "enum": [
                          "search_credit_cap",
                          "team_daily_ceiling"
                        ],
                        "description": "WHICH limit was reached. `search_credit_cap` is the search's OWN `creditCap`, set by whoever made it. `team_daily_ceiling` is the team-wide daily ceiling (an admin's setting, in the dashboard under Settings), reached because of what the team's OTHER searches spent today. Telling a subscriber \"credits\" about the second sends them to raise a cap that was never binding."
                      },
                      "scope": {
                        "type": "string",
                        "enum": [
                          "search",
                          "team"
                        ],
                        "description": "Which thing the cap belongs to, so you can route without matching on `cap`'s spelling."
                      },
                      "capCredits": {
                        "type": "integer",
                        "description": "The cap's value in credits \u2014 the number that was reached."
                      },
                      "creditsSpent": {
                        "type": "integer",
                        "description": "What THIS RUN spent. 0 is a real value and means the run was SKIPPED: the day's ceiling was already spent before it started, so it never called the provider."
                      },
                      "teamSpentToday": {
                        "type": "integer",
                        "nullable": true,
                        "description": "The team's keyword spend TODAY (UTC), INCLUDING this run \u2014 the figure a ceiling is measured against, and the same number GET /api/v1/credits reports as `spentToday`. `null` means NO TEAM CEILING was in force for this run, never zero."
                      },
                      "skipped": {
                        "type": "boolean",
                        "description": "True when the run never started because the day's ceiling was already spent; false when it ran and was stopped at the cap part-way."
                      },
                      "reachedAt": {
                        "type": "string",
                        "format": "date-time",
                        "description": "When the cap was reached, from the run's own clock."
                      },
                      "reason": {
                        "type": "string",
                        "nullable": true,
                        "description": "The run's own sentence for the ending, passed through untouched. Safe to display: these events fire only for the two CAP endings, whose reasons are our own copy built from our own numbers \u2014 no provider text can reach this field, because no provider-failure ending emits a spend event."
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx as soon as the payload is durably accepted; process asynchronously afterwards."
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "X-API-Key"
      }
    },
    "parameters": {
      "JobId": {
        "name": "jobId",
        "in": "path",
        "required": true,
        "description": "Public API `jobId` returned by an asynchronous job creation endpoint.",
        "schema": {
          "type": "string",
          "format": "uuid"
        },
        "example": "00000000-0000-4000-8000-000000000001"
      }
    },
    "schemas": {
      "Agent": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "status",
          "website",
          "profile",
          "dailyCredits",
          "plan",
          "launchedAt",
          "discoverRun",
          "peopleAdded",
          "sources",
          "id",
          "webhook",
          "trial",
          "heyreach"
        ],
        "properties": {
          "heyreach": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/HeyReachAutoPush"
              },
              {
                "type": "null"
              }
            ],
            "description": "HeyReach auto-push: the campaign new Agent Leads go to (each with its ICP % and signal as custom fields), or null. Set with `heyreach` on PUT /api/v1/agent."
          },
          "status": {
            "type": "string",
            "enum": [
              "draft",
              "active"
            ],
            "description": "`active` once started; `draft` before that and after POST /api/v1/agent/stop."
          },
          "website": {
            "type": [
              "string",
              "null"
            ],
            "description": "The team's website, a bare domain such as `acme.com`."
          },
          "profile": {
            "$ref": "#/components/schemas/AgentProfile"
          },
          "dailyCredits": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 50,
            "maximum": 20000,
            "description": "The daily credit limit, or null until one is set."
          },
          "plan": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/AgentPlan"
              },
              {
                "type": "null"
              }
            ],
            "description": "How `dailyCredits` is split across the agent's sources. Null until a limit is set."
          },
          "launchedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the agent was last started."
          },
          "discoverRun": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "The one-off people search the last start began (a Discover run)."
          },
          "peopleAdded": {
            "type": "boolean",
            "description": "True once the people that search found are watched. The worker adds them when the search finishes, usually a few minutes after the start."
          },
          "sources": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AgentSource"
            },
            "description": "Every source the agent set up, active or stopped."
          },
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "This agent's id: what every agent route takes as `agentId`."
          },
          "webhook": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/AgentWebhook"
              },
              {
                "type": "null"
              }
            ]
          },
          "trial": {
            "oneOf": [
              {
                "type": "object",
                "properties": {
                  "peopleChecked": {
                    "type": "integer",
                    "const": 1000
                  },
                  "maxPeople": {
                    "type": "integer",
                    "const": 20
                  }
                }
              },
              {
                "type": "null"
              }
            ],
            "description": "On a free trial: the agent's own allowance (1,000 people checked in all, up to 20 people). Null on a paid plan."
          }
        }
      },
      "HeyReachCampaign": {
        "type": "object",
        "required": [
          "id",
          "name",
          "status",
          "accountIds",
          "canTakeLeads"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "The campaign id (digits)."
          },
          "name": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "IN_PROGRESS",
              "PAUSED",
              "DRAFT",
              "SCHEDULED",
              "STARTING"
            ]
          },
          "accountIds": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "description": "The campaign's LinkedIn sender accounts."
          },
          "canTakeLeads": {
            "type": "boolean",
            "description": "False when the campaign has no LinkedIn sender yet."
          }
        }
      },
      "HeyReachAutoPush": {
        "type": "object",
        "required": [
          "campaignId",
          "campaignName",
          "autoSend",
          "filters",
          "since",
          "paused"
        ],
        "properties": {
          "campaignId": {
            "type": "string"
          },
          "campaignName": {
            "type": "string"
          },
          "autoSend": {
            "type": "boolean",
            "description": "Push new leads moving forward: each new lead that passes `filters`, once enriched."
          },
          "filters": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AgentFilterRow"
            },
            "maxItems": 20
          },
          "since": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When autoSend was switched on; only leads found from then go automatically."
          },
          "paused": {
            "oneOf": [
              {
                "type": "null"
              },
              {
                "type": "object",
                "required": [
                  "reason",
                  "at",
                  "message"
                ],
                "properties": {
                  "reason": {
                    "type": "string",
                    "enum": [
                      "invalid_key",
                      "campaign_closed"
                    ]
                  },
                  "at": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date-time"
                  },
                  "message": {
                    "type": "string"
                  }
                }
              }
            ],
            "description": "Why the worker stopped sending, or null while it sends."
          }
        },
        "description": "An auto-push rule: the campaign one agent's or one source's new leads go to. Only ENRICHED leads are sent; a lead with no LinkedIn profile URL is skipped (`skippedNoLinkedin`); a person is never sent twice to the same campaign (`alreadyInCampaign`); a paused campaign is never resumed, and a finished one is never restarted."
      },
      "HeyReachAutoPushInput": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "campaignId"
        ],
        "properties": {
          "campaignId": {
            "type": [
              "string",
              "null"
            ],
            "pattern": "^\\d{1,18}$",
            "description": "The campaign, from GET /api/v1/integrations/heyreach/campaigns. null removes the auto-push."
          },
          "autoSend": {
            "type": "boolean",
            "description": "Push new leads moving forward. Default true."
          },
          "filters": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AgentFilterRow"
            },
            "maxItems": 20,
            "description": "Which leads go; every row must pass. A source rule cannot use icp, signal or engagementCount."
          },
          "pushHistoric": {
            "type": "boolean",
            "description": "Also send the leads already found that pass the filters, once."
          }
        }
      },
      "HeyReachStatus": {
        "type": "object",
        "required": [
          "connected",
          "connectedAt",
          "keyRejected",
          "autoPush"
        ],
        "properties": {
          "connected": {
            "type": "boolean"
          },
          "connectedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "keyRejected": {
            "type": "boolean",
            "description": "HeyReach rejected the stored key; auto-push waits until it is reconnected on the dashboard."
          },
          "autoPush": {
            "type": "array",
            "items": {
              "allOf": [
                {
                  "$ref": "#/components/schemas/HeyReachAutoPush"
                },
                {
                  "type": "object",
                  "properties": {
                    "scope": {
                      "type": "string",
                      "enum": [
                        "agent",
                        "source"
                      ]
                    },
                    "agentId": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "sourceId": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              ]
            }
          }
        }
      },
      "HeyReachSendRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "campaignId"
        ],
        "properties": {
          "campaignId": {
            "type": "string",
            "pattern": "^\\d{1,18}$"
          },
          "leadIds": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uuid"
            },
            "minItems": 1,
            "maxItems": 1000,
            "description": "Leads to send. Only enriched leads are sent."
          },
          "agentId": {
            "type": "string",
            "format": "uuid",
            "description": "With leadIds from Agent Leads: adds ICP % and signal as custom fields."
          },
          "people": {
            "type": "array",
            "minItems": 1,
            "maxItems": 1000,
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "profileUrl"
              ],
              "properties": {
                "profileUrl": {
                  "type": "string",
                  "description": "A LinkedIn profile URL (linkedin.com/in/…)."
                },
                "name": {
                  "type": "string"
                },
                "jobTitle": {
                  "type": "string"
                },
                "company": {
                  "type": "string"
                },
                "country": {
                  "type": "string"
                },
                "email": {
                  "type": "string"
                }
              }
            },
            "description": "Influencers found by Discover, instead of leadIds."
          }
        },
        "description": "Exactly one of `leadIds` or `people`."
      },
      "HeyReachSendResult": {
        "type": "object",
        "required": [
          "sent",
          "added",
          "updated",
          "failed",
          "skippedNoLinkedin",
          "alreadyInCampaign",
          "notFound",
          "recorded",
          "campaign"
        ],
        "properties": {
          "sent": {
            "type": "integer",
            "description": "People handed to HeyReach."
          },
          "added": {
            "type": "integer"
          },
          "updated": {
            "type": "integer",
            "description": "Already in the campaign from outside Cornersight; HeyReach updated them."
          },
          "failed": {
            "type": "integer",
            "description": "HeyReach could not add them."
          },
          "skippedNoLinkedin": {
            "type": "integer",
            "description": "No LinkedIn profile URL: never sent."
          },
          "alreadyInCampaign": {
            "type": "integer",
            "description": "Already sent to this campaign by Cornersight (or queued): not sent again."
          },
          "notFound": {
            "type": "integer",
            "description": "Lead ids that are not enriched leads on this team."
          },
          "recorded": {
            "type": "boolean",
            "description": "False only on a server without the delivery record yet: the send went through unrecorded."
          },
          "campaign": {
            "type": "object",
            "required": [
              "id",
              "name"
            ],
            "properties": {
              "id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              }
            }
          }
        }
      },
      "SourceHeyReachConfig": {
        "type": "object",
        "required": [
          "sourceId",
          "type",
          "username",
          "heyreach"
        ],
        "properties": {
          "sourceId": {
            "type": "string",
            "format": "uuid"
          },
          "type": {
            "type": "string",
            "enum": [
              "person",
              "company",
              "post",
              "keyword"
            ]
          },
          "username": {
            "type": "string",
            "description": "The source's stored identifier: a handle, a post URN or a search's terms."
          },
          "heyreach": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/HeyReachAutoPush"
              },
              {
                "type": "null"
              }
            ]
          },
          "historic": {
            "type": "object",
            "required": [
              "matched",
              "queued"
            ],
            "properties": {
              "matched": {
                "type": "integer"
              },
              "queued": {
                "type": "integer"
              }
            },
            "description": "With pushHistoric: how many past leads matched, and how many were queued (the rest were already in the campaign)."
          }
        }
      },
      "AgentError": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string"
          },
          "code": {
            "type": "string",
            "description": "`agent_not_found`, `agent_no_topics`, `agent_daily_credits_required`, `agent_not_started`, `agent_unavailable`, `trial_agent_limit`, `no_webhook` or `unknown_field`."
          },
          "launch": {
            "$ref": "#/components/schemas/AgentLaunchReport",
            "description": "With `agent_not_started`: what the start tried and why each part was refused."
          }
        }
      },
      "AgentLaunchReport": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "plan",
          "dailyCredits",
          "created",
          "reused",
          "discoverRun",
          "stopped",
          "notStopped",
          "failed"
        ],
        "properties": {
          "plan": {
            "$ref": "#/components/schemas/AgentPlan"
          },
          "dailyCredits": {
            "type": "integer"
          },
          "created": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Topic searches created (or resumed) by this start."
          },
          "reused": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Live searches the team already had on one of the topics, watched as they are and never re-capped."
          },
          "discoverRun": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "The one-off people search. Null when the plan watches nobody."
          },
          "stopped": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Sources from the previous start that were untracked first."
          },
          "notStopped": {
            "type": "array",
            "items": {
              "type": "object"
            },
            "description": "Earlier sources that could not be untracked, as in AgentStopReport."
          },
          "failed": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "part",
                "error",
                "status"
              ],
              "properties": {
                "part": {
                  "type": "string",
                  "description": "The topic, or `people search`."
                },
                "error": {
                  "type": "string"
                },
                "code": {
                  "type": "string"
                },
                "status": {
                  "type": "integer"
                }
              }
            },
            "description": "Topics (or the people search) that could not start, with the keyword create's own refusal (balance, trial limit, and so on). The rest started."
          }
        }
      },
      "AgentLead": {
        "type": "object",
        "required": [
          "id",
          "name",
          "linkedinUrl",
          "jobTitle",
          "company",
          "country",
          "icp",
          "icpReasons",
          "signal",
          "signalReasons",
          "engagementCount",
          "engagements",
          "engagementType",
          "source",
          "topic",
          "detectedAt"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "The person's newest lead row: the id POST /api/v1/agent/push takes, and the row a push sends."
          },
          "name": {
            "type": "string"
          },
          "linkedinUrl": {
            "type": [
              "string",
              "null"
            ]
          },
          "avatarUrl": {
            "type": [
              "string",
              "null"
            ]
          },
          "jobTitle": {
            "type": [
              "string",
              "null"
            ]
          },
          "company": {
            "type": [
              "string",
              "null"
            ]
          },
          "companyIndustry": {
            "type": [
              "string",
              "null"
            ]
          },
          "companyEmployeeCount": {
            "type": [
              "integer",
              "null"
            ]
          },
          "companyStaffRange": {
            "type": [
              "string",
              "null"
            ]
          },
          "companyDomain": {
            "type": [
              "string",
              "null"
            ]
          },
          "companyDescription": {
            "type": [
              "string",
              "null"
            ]
          },
          "companyLocation": {
            "type": [
              "string",
              "null"
            ]
          },
          "companyWebsite": {
            "type": [
              "string",
              "null"
            ]
          },
          "companyLinkedinUrl": {
            "type": [
              "string",
              "null"
            ]
          },
          "country": {
            "type": [
              "string",
              "null"
            ]
          },
          "icp": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 40,
            "maximum": 100,
            "description": "How well they fit the profile."
          },
          "icpReasons": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Each part, e.g. \"Title: Founder\", \"Industry unknown (half)\"."
          },
          "icpParts": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AgentIcpPart"
            },
            "description": "Each part of the profile checked, its outcome and its points: the data behind icpReasons."
          },
          "signal": {
            "type": "string",
            "enum": [
              "extra_strong",
              "strong",
              "medium",
              "weak"
            ]
          },
          "signalReasons": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "engagementCount": {
            "type": "integer"
          },
          "engagements": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AgentEngagement"
            },
            "description": "Every engagement the agent saw from them, strongest first. engagementType, source, post and topic above describe the first."
          },
          "engagementType": {
            "type": "string",
            "description": "The strongest engagement: Like, Comment, Reply, Author or a reaction name."
          },
          "commentText": {
            "type": [
              "string",
              "null"
            ]
          },
          "source": {
            "type": "object",
            "required": [
              "id",
              "label",
              "kind"
            ],
            "properties": {
              "id": {
                "type": "string"
              },
              "label": {
                "type": "string"
              },
              "kind": {
                "type": "string",
                "enum": [
                  "person",
                  "company",
                  "keyword",
                  "other"
                ]
              },
              "active": {
                "type": "boolean"
              }
            }
          },
          "postText": {
            "type": [
              "string",
              "null"
            ]
          },
          "postUrl": {
            "type": [
              "string",
              "null"
            ]
          },
          "topic": {
            "type": [
              "string",
              "null"
            ],
            "description": "The agent topic the post was about."
          },
          "posterTopic": {
            "type": [
              "string",
              "null"
            ],
            "description": "For a watched person, the topic their own posts are about."
          },
          "detectedAt": {
            "type": "string",
            "format": "date-time",
            "description": "Their most recent engagement."
          },
          "enriching": {
            "type": "boolean",
            "description": "Always false here: a person whose job title and company are not filled in yet is counted in the list's `enriching` instead."
          }
        },
        "description": "Agent Leads is RANKED, one row per person: the job title must match (it gates); everything else lowers the ICP % (title 40, country 30, industry 15, company size 15; a part enrichment could not fill earns half; parts left empty are not counted), from 40 to 100. Each person's signal is extra_strong (engaged twice or more), strong (a comment on a post about the topics, a comment on a lead-magnet post, or a 100% ICP match on a post about the topics), medium (any other engagement) or weak (only an engagement with a hiring post or personal news). Ranked by ICP %, then signal, then newest: exactly the dashboard's Agent Leads table, built by the same code."
      },
      "AgentEngagement": {
        "type": "object",
        "required": [
          "engagementType",
          "commentText",
          "sourceId",
          "sourceLabel",
          "sourceKind",
          "postUrl",
          "topic",
          "posterTopic",
          "detectedAt",
          "signalPoints",
          "signalReasons"
        ],
        "properties": {
          "engagementType": {
            "type": "string",
            "description": "Like, another reaction, Comment or Reply."
          },
          "commentText": {
            "type": [
              "string",
              "null"
            ]
          },
          "sourceId": {
            "type": "string",
            "format": "uuid"
          },
          "sourceLabel": {
            "type": "string"
          },
          "sourceKind": {
            "type": "string",
            "enum": [
              "keyword",
              "profile",
              "company",
              "post"
            ]
          },
          "postUrl": {
            "type": [
              "string",
              "null"
            ]
          },
          "topic": {
            "type": [
              "string",
              "null"
            ],
            "description": "Which of the agent's topics the post is about."
          },
          "posterTopic": {
            "type": [
              "string",
              "null"
            ],
            "description": "For a watched person, the topic their own posts are about."
          },
          "detectedAt": {
            "type": "string",
            "format": "date-time"
          },
          "signalPoints": {
            "type": "integer"
          },
          "signalReasons": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Why this engagement scored what it did, e.g. \"Commented\", \"Post about your topics\", \"Hiring post (weak)\"."
          }
        },
        "description": "One engagement by an Agent Lead."
      },
      "AgentIcpPart": {
        "type": "object",
        "required": [
          "part",
          "outcome",
          "value",
          "matched",
          "earned",
          "weight"
        ],
        "properties": {
          "part": {
            "type": "string",
            "enum": [
              "title",
              "country",
              "industry",
              "size"
            ]
          },
          "outcome": {
            "type": "string",
            "enum": [
              "match",
              "unknown",
              "miss"
            ],
            "description": "unknown earns half the weight."
          },
          "value": {
            "type": [
              "string",
              "null"
            ],
            "description": "What the lead has, or null when enrichment found nothing."
          },
          "matched": {
            "type": [
              "string",
              "null"
            ],
            "description": "The profile entry it matched."
          },
          "earned": {
            "type": "number"
          },
          "weight": {
            "type": "integer",
            "description": "title 40, country 30, industry 15, company size 15."
          }
        },
        "description": "One part of the ICP % for one lead."
      },
      "AgentLeadList": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "agentId",
          "leads",
          "checked",
          "enriching",
          "total",
          "limit",
          "offset",
          "hasMore",
          "firstRun"
        ],
        "properties": {
          "leads": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AgentLead"
            },
            "description": "This page of Agent Leads, ranked."
          },
          "checked": {
            "type": "integer",
            "description": "Everyone the agent's sources captured. The team pays one credit per new person checked, shown or not."
          },
          "enriching": {
            "type": "integer",
            "description": "Of those, how many are still being enriched: they are checked once their title and company fill in."
          },
          "total": {
            "type": "integer",
            "description": "How many people show, across all pages."
          },
          "limit": {
            "type": "integer"
          },
          "offset": {
            "type": "integer"
          },
          "hasMore": {
            "type": "boolean",
            "description": "Whether a later page exists. Advance `offset` by `limit`."
          },
          "agentId": {
            "type": "string",
            "format": "uuid"
          },
          "firstRun": {
            "$ref": "#/components/schemas/AgentFirstRun"
          }
        }
      },
      "AgentPlan": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "perTopic",
          "people",
          "total"
        ],
        "description": "How the daily limit is split, the figure the dashboard's step 3 shows. Half goes to the topics, shared evenly; the other half watches as many people as it pays for (at most 60, each capped at 25 credits a sync), and what the people cannot use goes back to the topics. Every share is rounded down, so `total` never passes the limit.",
        "properties": {
          "perTopic": {
            "type": "integer",
            "minimum": 0,
            "description": "Each topic search's credit cap per daily run."
          },
          "people": {
            "type": "integer",
            "minimum": 0,
            "maximum": 60,
            "description": "How many people the agent watches, each capped at 25 credits a sync. Also the most the one-off people search can charge (one credit per person found)."
          },
          "total": {
            "type": "integer",
            "minimum": 0,
            "description": "The most every source together can spend in one day. It recurs daily until the agent is stopped."
          }
        }
      },
      "AgentProfile": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "summary",
          "titles",
          "industries",
          "companySizes",
          "countries",
          "topics",
          "competitors"
        ],
        "description": "Who the team sells to and what the agent searches for. Agent Leads ranks people against it: the job title must match for a lead to show, and country, industry and company size only lower the ICP % (title 40, country 30, industry 15, company size 15; an unknown part earns half), from 40 to 100. Departments and seniority are not checked.",
        "properties": {
          "summary": {
            "type": "string",
            "description": "One sentence on what the team sells."
          },
          "titles": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Job titles the team sells to."
          },
          "industries": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Industries, as LinkedIn names them."
          },
          "companySizes": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "1-10",
                "11-50",
                "51-200",
                "201-500",
                "501-1000",
                "1001-5000",
                "5001-10000",
                "10001+"
              ]
            },
            "description": "LinkedIn company size ranges."
          },
          "countries": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "topics": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "What the agent searches LinkedIn posts for: one daily keyword search per topic. The first 10 also find the people to watch."
          },
          "competitors": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "name"
              ],
              "properties": {
                "name": {
                  "type": "string"
                },
                "domain": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "AgentResponse": {
        "type": "object",
        "required": [
          "agent",
          "agents"
        ],
        "properties": {
          "heyreachHistoric": {
            "type": "object",
            "required": [
              "matched",
              "queued"
            ],
            "properties": {
              "matched": {
                "type": "integer"
              },
              "queued": {
                "type": "integer"
              }
            },
            "description": "Only on PUT with heyreach.pushHistoric: how many past Agent Leads matched and how many were queued for HeyReach."
          },
          "agent": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/Agent"
              },
              {
                "type": "null"
              }
            ],
            "description": "Null when the team has never set an agent up."
          },
          "agents": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "id",
                "website",
                "status"
              ],
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid"
                },
                "website": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "status": {
                  "type": "string",
                  "enum": [
                    "draft",
                    "active"
                  ]
                }
              }
            },
            "description": "Every agent the team holds, one per website."
          }
        }
      },
      "AgentSource": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "id",
          "type",
          "label",
          "active"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "The source id, as GET /api/v1/sources reports it and the per-source routes take it."
          },
          "type": {
            "type": "string",
            "enum": [
              "person",
              "company",
              "keyword"
            ],
            "description": "A topic search is `keyword`; a watched person is `person`."
          },
          "label": {
            "type": "string",
            "description": "A topic search's topic, or a watched person's name."
          },
          "active": {
            "type": "boolean",
            "description": "False once the source is stopped. Its leads are kept and still listed."
          }
        }
      },
      "AgentSpendConfirmation": {
        "type": "object",
        "required": [
          "error",
          "code",
          "estimatedDailyMax",
          "dailyCredits",
          "plan",
          "discoverCost",
          "topics"
        ],
        "properties": {
          "error": {
            "type": "string"
          },
          "code": {
            "type": "string",
            "const": "spend_confirmation_required"
          },
          "estimatedDailyMax": {
            "type": "integer",
            "description": "The most the agent can spend in one day (`plan.total`). It recurs daily until the agent is stopped."
          },
          "dailyCredits": {
            "type": "integer"
          },
          "plan": {
            "$ref": "#/components/schemas/AgentPlan"
          },
          "discoverCost": {
            "type": "integer",
            "description": "The one-off most the people search can charge: one credit per person found."
          },
          "topics": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "trialPeopleChecked": {
            "type": "integer",
            "description": "On a free trial: the people the agent checks in all (1,000), instead of a daily limit."
          }
        }
      },
      "AgentStartRequest": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "dailyCredits": {
            "type": "integer",
            "minimum": 1,
            "description": "The daily limit to start with (clamped to 50..20000). Omit to use the one on the profile; one of the two is required. On a free trial there is no limit to choose: the agent checks 1,000 people in all. A value sent here is also saved on the profile."
          },
          "confirmSpend": {
            "type": "boolean",
            "description": "Authorises the recurring daily spend. Without `true` the start is refused 409 `spend_confirmation_required` with the figures, and nothing is stopped or created."
          },
          "agentId": {
            "type": "string",
            "format": "uuid",
            "description": "Which agent: a team may hold several, one per website (GET /api/v1/agent lists them in `agents`). Omit for the team's first agent."
          }
        }
      },
      "AgentStopReport": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "stopped",
          "notStopped"
        ],
        "properties": {
          "stopped": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The sources untracked by this call."
          },
          "notStopped": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "id",
                "label",
                "error"
              ],
              "properties": {
                "id": {
                  "type": "string",
                  "format": "uuid"
                },
                "label": {
                  "type": "string"
                },
                "error": {
                  "type": "string"
                }
              }
            },
            "description": "Sources that could not be untracked, with why (a trial team's own sources cannot be)."
          }
        }
      },
      "AgentUpdateRequest": {
        "type": "object",
        "additionalProperties": false,
        "minProperties": 1,
        "description": "A PARTIAL update: only the fields sent change, and a list sent REPLACES that list (send [] to clear it). Saving starts nothing and charges nothing; a running agent keeps its sources and caps until it is started again. A list over its cap, an unknown company size, a website that is not a public domain or a malformed webhook is a 400 naming the field, never trimmed. `create: true` adds another agent (another website) instead of changing one; a free trial has one. Agent Leads is RANKED, one row per person: the job title must match (it gates); everything else lowers the ICP % (title 40, country 30, industry 15, company size 15; a part enrichment could not fill earns half; parts left empty are not counted), from 40 to 100. Each person's signal is extra_strong (engaged twice or more), strong (a comment on a post about the topics, a comment on a lead-magnet post, or a 100% ICP match on a post about the topics), medium (any other engagement) or weak (only an engagement with a hiring post or personal news). Ranked by ICP %, then signal, then newest: exactly the dashboard's Agent Leads table, built by the same code.",
        "properties": {
          "heyreach": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/HeyReachAutoPushInput"
              },
              {
                "type": "null"
              }
            ],
            "description": "HeyReach auto-push of Agent Leads: { campaignId, autoSend, filters, pushHistoric }, or null to remove it. Checked before anything is written: the campaign must exist and take leads, and HeyReach must be connected. Only enriched leads with a LinkedIn profile are sent, each person once per campaign; a paused campaign is never resumed."
          },
          "website": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 300,
            "description": "The team's website, e.g. `acme.com` or `https://www.acme.com`; stored as the bare domain. null clears it. Drafting a profile from the website is a dashboard-only step."
          },
          "summary": {
            "type": "string",
            "maxLength": 2000,
            "description": "One sentence on what the team sells; stored up to 400 characters."
          },
          "titles": {
            "type": "array",
            "maxItems": 15,
            "items": {
              "type": "string",
              "maxLength": 200
            },
            "description": "Job titles the team sells to. At most 15."
          },
          "industries": {
            "type": "array",
            "maxItems": 15,
            "items": {
              "type": "string",
              "maxLength": 200
            },
            "description": "Industries, as LinkedIn names them. At most 15."
          },
          "companySizes": {
            "type": "array",
            "maxItems": 8,
            "items": {
              "type": "string",
              "enum": [
                "1-10",
                "11-50",
                "51-200",
                "201-500",
                "501-1000",
                "1001-5000",
                "5001-10000",
                "10001+"
              ]
            },
            "description": "LinkedIn's company size ranges only."
          },
          "countries": {
            "type": "array",
            "maxItems": 15,
            "items": {
              "type": "string",
              "maxLength": 200
            },
            "description": "Countries. At most 15."
          },
          "topics": {
            "type": "array",
            "maxItems": 20,
            "items": {
              "type": "string",
              "maxLength": 200
            },
            "description": "What the agent searches for: one daily keyword search per topic. At most 20; quotes and brackets are removed and each is kept to 60 characters."
          },
          "competitors": {
            "type": "array",
            "maxItems": 8,
            "items": {
              "oneOf": [
                {
                  "type": "string",
                  "maxLength": 200
                },
                {
                  "type": "object",
                  "additionalProperties": false,
                  "required": [
                    "name"
                  ],
                  "properties": {
                    "name": {
                      "type": "string",
                      "maxLength": 200
                    },
                    "domain": {
                      "type": "string",
                      "maxLength": 200
                    }
                  }
                }
              ]
            },
            "description": "Competitors by name, optionally with a domain. At most 8."
          },
          "dailyCredits": {
            "type": "integer",
            "minimum": 1,
            "description": "The daily credit limit. Clamped to 50..20000. Takes effect the next time the agent is started. Ignored on a free trial, which checks 1,000 people in all."
          },
          "agentId": {
            "type": "string",
            "format": "uuid",
            "description": "Which agent: a team may hold several, one per website (GET /api/v1/agent lists them in `agents`). Omit for the team's first agent."
          },
          "create": {
            "type": "boolean",
            "description": "Add another agent with these settings. Refused 403 `trial_agent_limit` on a free trial."
          },
          "webhook": {
            "oneOf": [
              {
                "type": "object",
                "additionalProperties": false,
                "required": [
                  "url"
                ],
                "properties": {
                  "url": {
                    "type": "string",
                    "description": "A public http(s) URL."
                  },
                  "autoSend": {
                    "type": "boolean"
                  },
                  "filters": {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/AgentFilterRow"
                    },
                    "maxItems": 20
                  },
                  "since": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Read-only: accepted so a GET can be sent back, and ignored."
                  }
                }
              },
              {
                "type": "null"
              }
            ],
            "description": "Where Agent Leads are sent and which (see AgentWebhook), replaced whole: an omitted autoSend is false and omitted filters are none. null removes it."
          }
        }
      },
      "AgentFilterRow": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "column",
          "operator",
          "value"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "column": {
            "type": "string",
            "enum": [
              "icp",
              "signal",
              "engagementCount",
              "name",
              "jobTitle",
              "company",
              "companyIndustry",
              "companyEmployeeCount",
              "country",
              "engagementType"
            ]
          },
          "operator": {
            "type": "string",
            "description": "Numbers (icp, engagementCount, companyEmployeeCount): at_least, at_most, equals. signal: at_least, equals, not_equals over weak < medium < strong < extra_strong. engagementType: equals, not_equals comment|like. Text: contains, equals, not_contains, not_equals. Any other operator is refused 400."
          },
          "value": {
            "type": "string",
            "description": "A number for number columns; weak, medium, strong or extra_strong for signal; comment or like for engagementType; any text for text columns. Any other value is refused 400. An empty value filters nothing."
          }
        },
        "description": "One row of the Agent Leads table's Filters; every row must pass."
      },
      "AgentWebhook": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "url",
          "autoSend",
          "filters",
          "since"
        ],
        "properties": {
          "url": {
            "type": "string",
            "description": "A public http(s) URL. Each lead is one signed POST of the team's `lead.detected` event for that person's newest engagement row (see the Webhooks section of the docs), retried like the team's other webhooks. It carries no ICP % or signal; read those from GET /api/v1/agent/leads."
          },
          "autoSend": {
            "type": "boolean",
            "description": "Push new leads moving forward: each NEW lead that passes `filters` is sent as it arrives."
          },
          "filters": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AgentFilterRow"
            },
            "maxItems": 20,
            "description": "Which leads go: the table's Filters rows. Empty sends every Agent Lead."
          },
          "since": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When autoSend was switched on; only leads found from then are sent automatically. Set by the save, never by the caller."
          }
        },
        "description": "Where Agent Leads are sent and which. Saved WHOLE: an omitted autoSend is false and omitted filters are none, so send all three to change one. Past leads go only with POST /api/v1/agent/push."
      },
      "AgentFirstRun": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "done",
          "peopleReady",
          "sourcesDone",
          "sourcesTotal",
          "enriching"
        ],
        "properties": {
          "done": {
            "type": "boolean",
            "description": "False while the agent's first run is going: its people not yet added, a source not yet synced once, or many people still enriching. The dashboard shows progress instead of leads until it is true; it is true a day after launch whatever."
          },
          "peopleReady": {
            "type": "boolean"
          },
          "sourcesDone": {
            "type": "integer"
          },
          "sourcesTotal": {
            "type": "integer"
          },
          "enriching": {
            "type": "integer"
          }
        }
      },
      "AgentStopRequest": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "agentId": {
            "type": "string",
            "format": "uuid",
            "description": "Which agent: a team may hold several, one per website (GET /api/v1/agent lists them in `agents`). Omit for the team's first agent."
          }
        }
      },
      "AgentPushRequest": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "agentId": {
            "type": "string",
            "format": "uuid",
            "description": "Which agent: a team may hold several, one per website (GET /api/v1/agent lists them in `agents`). Omit for the team's first agent."
          },
          "leadIds": {
            "type": "array",
            "maxItems": 5000,
            "items": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Lead ids from GET /api/v1/agent/leads. Omit to send every Agent Lead that passes the webhook's filters."
          }
        }
      },
      "AgentPushResult": {
        "type": "object",
        "required": [
          "agentId",
          "queued",
          "considered"
        ],
        "properties": {
          "agentId": {
            "type": "string",
            "format": "uuid"
          },
          "queued": {
            "type": "integer",
            "description": "Leads marked for delivery now. A lead already delivered or on its way is never sent again; one whose delivery failed is tried again."
          },
          "considered": {
            "type": "integer",
            "description": "The Agent Leads this call looked at: those passing the webhook's filters, or the `leadIds` that are Agent Leads (an id that is not one is skipped)."
          }
        }
      },
      "JobAccepted": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "jobId"
        ],
        "properties": {
          "jobId": {
            "type": "string",
            "format": "uuid",
            "description": "Identifier of the asynchronous job to poll."
          }
        }
      },
      "ErrorResponse": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "Human-readable `error` message."
          }
        }
      },
      "Untracked": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "ok",
          "username",
          "profileType",
          "untracked"
        ],
        "properties": {
          "ok": {
            "type": "boolean"
          },
          "username": {
            "type": "string",
            "description": "The (normalized) public identifier of the deleted tracked profile."
          },
          "profileType": {
            "type": "string",
            "enum": [
              "person",
              "company"
            ]
          },
          "untracked": {
            "type": "boolean",
            "description": "Always `true` \u2014 the tracked profile and its cascaded records were deleted."
          }
        }
      },
      "JobStatus": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "status",
          "result",
          "error",
          "createdAt",
          "startedAt",
          "completedAt"
        ],
        "properties": {
          "status": {
            "type": "string",
            "description": "Lifecycle status. Non-terminal jobs are `pending` or `running`; terminal jobs are `completed` or `failed`.",
            "enum": [
              "pending",
              "running",
              "completed",
              "failed"
            ]
          },
          "result": {
            "description": "Generic `result` object. The exact fields vary by job type and by external LinkedIn API responses.",
            "type": [
              "object",
              "null"
            ],
            "additionalProperties": true
          },
          "error": {
            "type": [
              "string",
              "null"
            ],
            "description": "Human-readable failure reason when `status` is `failed`; otherwise `null`. Owned Cornersight text - it never contains the upstream data provider's raw response. Wording may change; branch on `errorCode`."
          },
          "errorCode": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "insufficient_enriching_credits",
              "not_found",
              "page_out_of_range",
              "invalid_request",
              "provider_rate_limited",
              "provider_access_denied",
              "provider_unavailable",
              "internal_error",
              null
            ],
            "description": "Stable, machine-readable reason for the failure when `status` is `failed`; `null` otherwise, and `null` on failures recorded before this field existed. Branch on this rather than parsing `error`, whose wording may change. Values are split by WHO MUST ACT: `insufficient_enriching_credits` - The team's enriching-credit balance is exhausted. YOURS to act on - check GET /api/v1/credits for the balance and reset time. `not_found` - The data provider has no record of the profile, company or post. YOURS to act on - it may be private, renamed, or deleted. `page_out_of_range` - There are no results at the requested `page`. YOURS to act on - request a lower page, and stop paging when a page returns fewer results than the one before it. `invalid_request` - A parameter was rejected. YOURS to act on - check the request against this endpoint's schema. `provider_rate_limited` - The data provider is rate-limiting Cornersight. NOT yours - nothing is wrong with the request; retry in a few minutes. `provider_access_denied` - Cornersight's access to the data provider was refused - a credential or subscription problem on CORNERSIGHT's side. NOT yours - nothing is wrong with your request or your account, and unlike provider_unavailable it will NOT clear on retry; it is logged for us to fix. `provider_unavailable` - The data provider is temporarily unavailable to Cornersight. NOT yours - nothing is wrong with the request; retry shortly. NOTE this also covers the provider reporting that CORNERSIGHT's own account cannot be served, which is never a problem with your credits. `internal_error` - Cornersight could not complete the job. NOT yours - the failure is logged for investigation; retrying may succeed."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "startedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "completedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "ProfileEnrichmentRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "username"
        ],
        "properties": {
          "creditCapPerSync": {
            "type": "integer",
            "format": "int32",
            "minimum": 1,
            "maximum": 2147483647,
            "nullable": true,
            "description": "The most credits ONE SYNC of this tracked source may spend \u2014 CAPTURE CAP COUNTS LEAD ROWS; BILLING CHARGES ONCE PER NEW PERSON PER SOURCE (tracked_profiles.credit_cap, migration 147). PER SYNC AND NOT A LIFETIME TOTAL: the source re-syncs on its own about every 24 hours and this bounds EACH of those runs. PER SYNC, EVERY SYNC: it bounds EACH sync of that one person, company page or post \u2014 the capture cap counts lead rows, while billing charges once per new person per source \u2014 not the first pull only and not the life of the source, and there is NO ESTIMATE, NO CONFIRMATION GATE AND NO TEAM CEILING behind it: nothing prices a sync before you set the cap, raising or lowering it never needs `confirmSpend`, and the team's `dailyCeiling` does not count a credit of it. \u26a0\ufe0f A KEYWORD SEARCH'S `creditCap` IS THE OTHER FIELD AND HAS ALL THREE: it is the daily bound on a RECURRING SWEEP, POST /api/v1/keyword/estimate prices it as `estimatedDailyMax`, `confirmSpend` gates a create or a raise with a `409 spend_confirmation_required`, and the team's `dailyCeiling` stops it \u2014 and `dailyCeiling` COUNTS KEYWORD SPEND ONLY, so no number of profile syncs can ever reach it. \u26a0 RENAMED from `creditCap` in 4.0.0 and NOT aliased: `creditCap` now names ONLY a keyword search's per-RUN cap, a different number on a different table, and sending it here is a 400 carrying `code: \"renamed_field\"`. A whole number from 1 to 2147483647, or `null` for NO LIMIT, which is what every source without one is. OMITTED LEAVES THE STORED VALUE ALONE; `null` CLEARS IT. It binds EVERY sync, not the first pull. AND IT IS HOW THE LIMIT IS CHANGED: calling this endpoint again for a source the team already tracks applies the value it carries, which is the only way to edit the setting over this API \u2014 the same \"a second create resumes the source and applies the settings it carries\" rule POST /api/v1/keyword/track follows. 0 is REFUSED rather than stored (a source that syncs and writes nothing is a PAUSED source, and `status` already says that), as are fractions and values past int4 \u2014 the worker resolves those to \"no cap\", so storing one would show a limit that binds nothing. \u26a0\ufe0f REQUIRES `saveTrackedProfile: true`: without it this call creates no tracked source for the limit to apply to, so sending it is a 400 rather than a 200 that dropped it. A CAP NEEDS A TRACKED SOURCE TO SIT ON \u2014 it is stored on the source row, so an enrichment that saves no source has nothing to carry it."
          },
          "username": {
            "$ref": "#/components/schemas/LinkedInUsername"
          },
          "saveTrackedProfile": {
            "type": "boolean",
            "default": false,
            "description": "Effective default is `false`. With `false`, Cornersight updates existing leads and the job fails if no compatible lead exists. With `true`, Cornersight creates or updates the tracked personal profile first. On a source the team already tracks, `true` applies the capture settings sent with it and queues a sync only if the source has NEVER completed one (or is being brought back from untracked); a source that has synced before is not re-synced, and the response says so with `syncId: null` and `syncNotQueuedReason`."
          },
          "captureReplies": {
            "type": "boolean",
            "default": true,
            "description": "Whether the tracked source captures reply authors as leads. False skips replies before lead writes and credits. Requires saveTrackedProfile: true."
          },
          "enrichLeads": {
            "type": "boolean",
            "default": true,
            "description": "Whether this source's leads are enriched. Default true, what every source has always done. RAW MODE when false: this source's engagers are still captured and charged exactly as before \u2014 one credit per NEW person per source, repeats free, the same ledger and caps \u2014 but NEVER enriched: no job title, company or country, and no enrichment provider call. Those leads end enrichment status `raw` and are read with GET /api/v1/leads/raw; they never appear in GET /api/v1/leads, /engagers, exports, webhooks or integrations. A plain setting, not a spend change: the price is the same either way, so it never needs `confirmSpend`. It applies to leads captured or processed AFTER the change \u2014 leads already enriched stay enriched and raw leads stay raw. Requires saveTrackedProfile: true \u2014 without it this call creates no source for the setting to sit on and is refused with a 400 rather than the field dropped. Re-tracking an existing source with it CHANGES the setting; omitting it leaves the stored value alone."
          },
          "mode": {
            "type": "string",
            "enum": [
              "engagers",
              "posts_only"
            ],
            "default": "engagers",
            "description": "WHAT ONE SYNC OF THIS TRACKED SOURCE DOES (tracked_profiles.sync_mode, migration 158). `engagers` is the default and what every tracked source has always done: sweep the posts and fan out across the reactions and comments on each one, writing a lead per engager and charging ONE CREDIT PER NEW PERSON FOR THIS SOURCE; repeat engagements are free \u2014 a single post with 300 distinct new people can cost up to 300 credits. `posts_only` fetches this source's NEW POSTS newest first and stops \u2014 no engagers, no leads, no enrichment \u2014 charging ONE CREDIT PER NEW POST, up to `postsPerSync`, and never twice for the same post. \u26a0 ASK WHICH IS WANTED BEFORE CREATING ANYTHING: \"watch what this person posts\" and \"find me the people who engage with them\" are different products at very different prices. \u26a0 `posts_only` REQUIRES `postsPerSync` AND `confirmSpend`. \u26a0 REQUIRES `saveTrackedProfile: true` \u2014 without it this call creates no source for a mode to apply to, and sending it is a 400 rather than a 200 that dropped it. Sending it for a source the team ALREADY tracks CHANGES its mode, which is how it is edited: switching an existing posts-only source TO `engagers` is a SPEND INCREASE and needs `confirmSpend`, while switching the other way never does. Read it back as `mode` on GET /api/v1/sources, where a posts-only source also reports `postsPerSync` and a `lastRun` carrying `postsFetched` and `creditsSpent`."
          },
          "postsPerSync": {
            "type": "integer",
            "minimum": 1,
            "maximum": 60,
            "description": "The most posts ONE SYNC of a posts-only source may fetch, 1-60 \u2014 and, because one post is one credit, the most it can cost in a day: the N in \"up to N credits a day\", which is the figure the customer confirms. REQUIRED with `mode: \"posts_only\"` and refused without it, because that sentence is what is being agreed to and there is no N to put in it otherwise. It NEVER BACKFILLS: the first sync buys up to N of the newest posts, and each later sync only posts newer than the newest one it already holds (never older ones it has not seen), so a day with no new post costs nothing. `creditCapPerSync` applies as well and the TIGHTER of the two binds a run, because for this mode one post IS one credit."
          },
          "confirmSpend": {
            "type": "boolean",
            "description": "Authorises the RECURRING charge a posts-only source creates. Without it the call is refused 409 `spend_confirmation_required` and NOTHING is created; that refusal carries `estimatedDailyMax` (equal to `postsPerSync`), `daysToExhaustAtCap` \u2014 both from the same shared estimate function every other spend surface quotes \u2014 and `remainingBalance`. SAY THE DAILY FIGURE AND WAIT FOR AN ANSWER, then re-send the identical request with `confirmSpend: true`. It is also what authorises switching an existing posts-only source back to the engagers sweep. This is a STANDING charge until the source is untracked, not a one-off."
          },
          "firstSyncPosts": {
            "type": "integer",
            "minimum": 1,
            "maximum": 50,
            "nullable": true,
            "description": "How many of the LATEST posts this source's FIRST sync collects: a whole number from 1 to 50, or `null` for the default (the latest 15 posts). With `firstSyncDays` as well, it caps how many of that window's posts are collected (tracked_profiles.first_sync_posts, migration 188). FIRST SYNC ONLY: every later sync checks the 4 newest posts, and a source that has already synced ignores it (re-adding an untracked source stores the new value, which takes effect only if that source has never synced). Every collected post's engagers are captured and charged as usual, and `creditCapPerSync` still bounds the first sync. Omitted leaves the stored value alone. Requires `saveTrackedProfile: true` (a 400 without it) and an ENGAGERS source: sent with `mode: \"posts_only\"` it is a 400, because a posts-only watch's first run takes its `postsPerSync` newest posts. A tracked post and a keyword search have no first-sync window: POST /api/v1/post/track and POST /api/v1/keyword/track do not take this field."
          },
          "firstSyncDays": {
            "type": "integer",
            "minimum": 1,
            "maximum": 90,
            "nullable": true,
            "description": "Collect only the posts PUBLISHED IN THE LAST N DAYS on this source's FIRST sync: a whole number from 1 to 90, or `null` for no time limit. At most 50 posts, or at most `firstSyncPosts` when both are sent; a post whose publish time is unknown is left out (tracked_profiles.first_sync_days, migration 188). FIRST SYNC ONLY: every later sync checks the 4 newest posts, and a source that has already synced ignores it (re-adding an untracked source stores the new value, which takes effect only if that source has never synced). Every collected post's engagers are captured and charged as usual, and `creditCapPerSync` still bounds the first sync. Omitted leaves the stored value alone. Requires `saveTrackedProfile: true` (a 400 without it) and an ENGAGERS source: sent with `mode: \"posts_only\"` it is a 400, because a posts-only watch's first run takes its `postsPerSync` newest posts. A tracked post and a keyword search have no first-sync window: POST /api/v1/post/track and POST /api/v1/keyword/track do not take this field."
          }
        }
      },
      "CompanyEnrichmentRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "username"
        ],
        "properties": {
          "creditCapPerSync": {
            "type": "integer",
            "format": "int32",
            "minimum": 1,
            "maximum": 2147483647,
            "nullable": true,
            "description": "The most credits ONE SYNC of this tracked source may spend \u2014 CAPTURE CAP COUNTS LEAD ROWS; BILLING CHARGES ONCE PER NEW PERSON PER SOURCE (tracked_profiles.credit_cap, migration 147). PER SYNC AND NOT A LIFETIME TOTAL: the source re-syncs on its own about every 24 hours and this bounds EACH of those runs. PER SYNC, EVERY SYNC: it bounds EACH sync of that one person, company page or post \u2014 the capture cap counts lead rows, while billing charges once per new person per source \u2014 not the first pull only and not the life of the source, and there is NO ESTIMATE, NO CONFIRMATION GATE AND NO TEAM CEILING behind it: nothing prices a sync before you set the cap, raising or lowering it never needs `confirmSpend`, and the team's `dailyCeiling` does not count a credit of it. \u26a0\ufe0f A KEYWORD SEARCH'S `creditCap` IS THE OTHER FIELD AND HAS ALL THREE: it is the daily bound on a RECURRING SWEEP, POST /api/v1/keyword/estimate prices it as `estimatedDailyMax`, `confirmSpend` gates a create or a raise with a `409 spend_confirmation_required`, and the team's `dailyCeiling` stops it \u2014 and `dailyCeiling` COUNTS KEYWORD SPEND ONLY, so no number of profile syncs can ever reach it. \u26a0 RENAMED from `creditCap` in 4.0.0 and NOT aliased: `creditCap` now names ONLY a keyword search's per-RUN cap, a different number on a different table, and sending it here is a 400 carrying `code: \"renamed_field\"`. A whole number from 1 to 2147483647, or `null` for NO LIMIT, which is what every source without one is. OMITTED LEAVES THE STORED VALUE ALONE; `null` CLEARS IT. It binds EVERY sync, not the first pull. AND IT IS HOW THE LIMIT IS CHANGED: calling this endpoint again for a source the team already tracks applies the value it carries, which is the only way to edit the setting over this API \u2014 the same \"a second create resumes the source and applies the settings it carries\" rule POST /api/v1/keyword/track follows. 0 is REFUSED rather than stored (a source that syncs and writes nothing is a PAUSED source, and `status` already says that), as are fractions and values past int4 \u2014 the worker resolves those to \"no cap\", so storing one would show a limit that binds nothing. \u26a0\ufe0f REQUIRES `saveTrackedProfile: true`: without it this call creates no tracked source for the limit to apply to, so sending it is a 400 rather than a 200 that dropped it. A CAP NEEDS A TRACKED SOURCE TO SIT ON \u2014 it is stored on the source row, so an enrichment that saves no source has nothing to carry it."
          },
          "username": {
            "$ref": "#/components/schemas/LinkedInUsername"
          },
          "saveTrackedProfile": {
            "type": "boolean",
            "default": false,
            "description": "Effective default is `false`. With `false`, Cornersight updates existing compatible company leads and the job fails if no compatible lead exists. With `true`, Cornersight creates or updates the tracked company profile first. On a source the team already tracks, `true` applies the capture settings sent with it and queues a sync only if the source has NEVER completed one (or is being brought back from untracked); a source that has synced before is not re-synced, and the response says so with `syncId: null` and `syncNotQueuedReason`."
          },
          "captureReplies": {
            "type": "boolean",
            "default": true,
            "description": "Whether the tracked source captures reply authors as leads. False skips replies before lead writes and credits. Requires saveTrackedProfile: true."
          },
          "enrichLeads": {
            "type": "boolean",
            "default": true,
            "description": "Whether this source's leads are enriched. Default true, what every source has always done. RAW MODE when false: this source's engagers are still captured and charged exactly as before \u2014 one credit per NEW person per source, repeats free, the same ledger and caps \u2014 but NEVER enriched: no job title, company or country, and no enrichment provider call. Those leads end enrichment status `raw` and are read with GET /api/v1/leads/raw; they never appear in GET /api/v1/leads, /engagers, exports, webhooks or integrations. A plain setting, not a spend change: the price is the same either way, so it never needs `confirmSpend`. It applies to leads captured or processed AFTER the change \u2014 leads already enriched stay enriched and raw leads stay raw. Requires saveTrackedProfile: true \u2014 without it this call creates no source for the setting to sit on and is refused with a 400 rather than the field dropped. Re-tracking an existing source with it CHANGES the setting; omitting it leaves the stored value alone."
          },
          "mode": {
            "type": "string",
            "enum": [
              "engagers",
              "posts_only"
            ],
            "default": "engagers",
            "description": "WHAT ONE SYNC OF THIS TRACKED SOURCE DOES (tracked_profiles.sync_mode, migration 158). `engagers` is the default and what every tracked source has always done: sweep the posts and fan out across the reactions and comments on each one, writing a lead per engager and charging ONE CREDIT PER NEW PERSON FOR THIS SOURCE; repeat engagements are free \u2014 a single post with 300 distinct new people can cost up to 300 credits. `posts_only` fetches this source's NEW POSTS newest first and stops \u2014 no engagers, no leads, no enrichment \u2014 charging ONE CREDIT PER NEW POST, up to `postsPerSync`, and never twice for the same post. \u26a0 ASK WHICH IS WANTED BEFORE CREATING ANYTHING: \"watch what this person posts\" and \"find me the people who engage with them\" are different products at very different prices. \u26a0 `posts_only` REQUIRES `postsPerSync` AND `confirmSpend`. \u26a0 REQUIRES `saveTrackedProfile: true` \u2014 without it this call creates no source for a mode to apply to, and sending it is a 400 rather than a 200 that dropped it. Sending it for a source the team ALREADY tracks CHANGES its mode, which is how it is edited: switching an existing posts-only source TO `engagers` is a SPEND INCREASE and needs `confirmSpend`, while switching the other way never does. Read it back as `mode` on GET /api/v1/sources, where a posts-only source also reports `postsPerSync` and a `lastRun` carrying `postsFetched` and `creditsSpent`."
          },
          "postsPerSync": {
            "type": "integer",
            "minimum": 1,
            "maximum": 60,
            "description": "The most posts ONE SYNC of a posts-only source may fetch, 1-60 \u2014 and, because one post is one credit, the most it can cost in a day: the N in \"up to N credits a day\", which is the figure the customer confirms. REQUIRED with `mode: \"posts_only\"` and refused without it, because that sentence is what is being agreed to and there is no N to put in it otherwise. It NEVER BACKFILLS: the first sync buys up to N of the newest posts, and each later sync only posts newer than the newest one it already holds (never older ones it has not seen), so a day with no new post costs nothing. `creditCapPerSync` applies as well and the TIGHTER of the two binds a run, because for this mode one post IS one credit."
          },
          "confirmSpend": {
            "type": "boolean",
            "description": "Authorises the RECURRING charge a posts-only source creates. Without it the call is refused 409 `spend_confirmation_required` and NOTHING is created; that refusal carries `estimatedDailyMax` (equal to `postsPerSync`), `daysToExhaustAtCap` \u2014 both from the same shared estimate function every other spend surface quotes \u2014 and `remainingBalance`. SAY THE DAILY FIGURE AND WAIT FOR AN ANSWER, then re-send the identical request with `confirmSpend: true`. It is also what authorises switching an existing posts-only source back to the engagers sweep. This is a STANDING charge until the source is untracked, not a one-off."
          },
          "firstSyncPosts": {
            "type": "integer",
            "minimum": 1,
            "maximum": 50,
            "nullable": true,
            "description": "How many of the LATEST posts this source's FIRST sync collects: a whole number from 1 to 50, or `null` for the default (the latest 15 posts). With `firstSyncDays` as well, it caps how many of that window's posts are collected (tracked_profiles.first_sync_posts, migration 188). FIRST SYNC ONLY: every later sync checks the 4 newest posts, and a source that has already synced ignores it (re-adding an untracked source stores the new value, which takes effect only if that source has never synced). Every collected post's engagers are captured and charged as usual, and `creditCapPerSync` still bounds the first sync. Omitted leaves the stored value alone. Requires `saveTrackedProfile: true` (a 400 without it) and an ENGAGERS source: sent with `mode: \"posts_only\"` it is a 400, because a posts-only watch's first run takes its `postsPerSync` newest posts. A tracked post and a keyword search have no first-sync window: POST /api/v1/post/track and POST /api/v1/keyword/track do not take this field."
          },
          "firstSyncDays": {
            "type": "integer",
            "minimum": 1,
            "maximum": 90,
            "nullable": true,
            "description": "Collect only the posts PUBLISHED IN THE LAST N DAYS on this source's FIRST sync: a whole number from 1 to 90, or `null` for no time limit. At most 50 posts, or at most `firstSyncPosts` when both are sent; a post whose publish time is unknown is left out (tracked_profiles.first_sync_days, migration 188). FIRST SYNC ONLY: every later sync checks the 4 newest posts, and a source that has already synced ignores it (re-adding an untracked source stores the new value, which takes effect only if that source has never synced). Every collected post's engagers are captured and charged as usual, and `creditCapPerSync` still bounds the first sync. Omitted leaves the stored value alone. Requires `saveTrackedProfile: true` (a 400 without it) and an ENGAGERS source: sent with `mode: \"posts_only\"` it is a 400, because a posts-only watch's first run takes its `postsPerSync` newest posts. A tracked post and a keyword search have no first-sync window: POST /api/v1/post/track and POST /api/v1/keyword/track do not take this field."
          }
        }
      },
      "ProfilePostsRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "username"
        ],
        "properties": {
          "username": {
            "$ref": "#/components/schemas/LinkedInUsername"
          },
          "limit": {
            "type": "integer",
            "minimum": 1,
            "maximum": 15,
            "default": 15,
            "description": "How many posts this page returns, 1-15 (default 15, which is also the most a page can hold). A smaller page is still followed by a `paginationToken` while more posts exist, and the next page starts where this one ended. A value outside 1-15, a fraction or a non-number is a 400 rather than a silent clamp."
          },
          "paginationToken": {
            "type": "string",
            "description": "Opaque cursor for fetching the next page of posts. Omit for the first page. When more posts exist, the completed job's `result` includes a `paginationToken` \u2014 pass it verbatim to get the next, non-overlapping page. Tokens are endpoint-specific and expire with history drift (a new post shifts the window by one)."
          }
        }
      },
      "CompanyPostsRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "username"
        ],
        "properties": {
          "username": {
            "$ref": "#/components/schemas/LinkedInUsername"
          },
          "paginationToken": {
            "type": "string",
            "description": "Opaque cursor for fetching the next page of posts. Omit for the first page. When more posts exist, the completed job's `result` includes a `paginationToken` \u2014 pass it verbatim to get the next, non-overlapping page. Tokens are endpoint-specific and expire with history drift (a new post shifts the window by one)."
          }
        }
      },
      "PostReactionsRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "postUrn"
        ],
        "properties": {
          "postUrn": {
            "type": "string",
            "minLength": 1,
            "description": "`postUrn` of a LinkedIn post this team holds: one of the 15 most recent posts of a tracked person, company page or tracked post; a post one of its keyword searches harvested (a `urn` from GET /api/v1/sources/{id}/kept-posts with a non-null `url`); or a post one of its posts-only watches saw (GET /api/v1/sources/{id}/posts). A keyword-search or posts-only post is read, never saved, and charges nothing.",
            "example": "urn:li:activity:0000000000000000000"
          },
          "page": {
            "type": "integer",
            "minimum": 0,
            "default": 0,
            "description": "Zero-based `page` number. The logical default is `0`. Pages are 50 rows and that is not configurable - there is no `size` parameter, and one sent in the body is ignored."
          },
          "source": {
            "type": "string",
            "enum": [
              "primary",
              "fallback"
            ],
            "description": "The `source` reported by the PREVIOUS page of this same sweep. Omit on `page: 0`. It keeps the sweep on the provider that is actually serving this post: when the primary has nothing, a fallback answers page 0, and a later page sent without `source` is asked of the primary again, comes back empty, and ends the sweep one page in. An unrecognised value is a 400 rather than an ignored field. THE SERVER DEFAULTS IT when it is omitted past page 0, to whichever provider served `page: 0` of the same post within the last hour; an explicit value always wins, `page: 0` is always decided afresh, and a sweep that starts past page 0 or pauses more than an hour between pages has nothing to default from - so send it anyway. The comment endpoints reject it - they pick a provider per request from an upstream HTTP 500, so there is no sweep-level choice to carry."
          }
        }
      },
      "PostEngagementRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "postUrn"
        ],
        "properties": {
          "postUrn": {
            "type": "string",
            "minLength": 1,
            "description": "`postUrn` of a LinkedIn post this team holds: one of the 15 most recent posts of a tracked person, company page or tracked post; a post one of its keyword searches harvested (a `urn` from GET /api/v1/sources/{id}/kept-posts with a non-null `url`); or a post one of its posts-only watches saw (GET /api/v1/sources/{id}/posts). A keyword-search or posts-only post is read, never saved, and charges nothing.",
            "example": "urn:li:activity:0000000000000000000"
          },
          "page": {
            "type": "integer",
            "minimum": 0,
            "default": 0,
            "description": "Zero-based `page` number. The logical default is `0`. Pages are 50 rows and that is not configurable - there is no `size` parameter, and one sent in the body is ignored."
          }
        }
      },
      "PostCommentsRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": ["postUrn"],
        "properties": {
          "postUrn": {
            "type": "string",
            "minLength": 1,
            "description": "`postUrn` of a LinkedIn post this team holds: one of the 15 most recent posts of a tracked person, company page or tracked post; a post one of its keyword searches harvested (a `urn` from GET /api/v1/sources/{id}/kept-posts with a non-null `url`); or a post one of its posts-only watches saw (GET /api/v1/sources/{id}/posts). A keyword-search or posts-only post is read, never saved, and charges nothing.",
            "example": "urn:li:activity:0000000000000000000"
          },
          "page": {
            "type": "integer",
            "minimum": 0,
            "default": 0,
            "description": "Zero-based provider page number."
          },
          "size": {
            "type": "integer",
            "minimum": 1,
            "maximum": 50,
            "default": 50,
            "description": "Top-level comments per page (1–50)."
          },
          "includeReplies": {
            "type": "boolean",
            "default": true,
            "description": "Include threaded replies in `data`. False returns top-level comments only."
          }
        }
      },
      "PostCommentAuthor": {
        "type": "object",
        "description": "Who wrote a comment - the same keys whichever provider served the row, each `null` (never omitted) when unknown.",
        "additionalProperties": false,
        "required": [
          "type",
          "username",
          "profileUrl",
          "url",
          "linkedinUrl",
          "name",
          "firstName",
          "lastName",
          "headline",
          "profilePicture",
          "entityUrn"
        ],
        "properties": {
          "type": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "person",
              "company",
              null
            ],
            "description": "`person`, `company` (a company page commenting as itself - never captured as a lead), or `null` when the provider names the commenter only by display name, which the primary provider does for company commenters."
          },
          "username": {
            "type": [
              "string",
              "null"
            ],
            "description": "A person's public LinkedIn handle (the /in/ slug). Always `null` for a company, and for a person whose provider row carried only their member URN (see `entityUrn`)."
          },
          "profileUrl": {
            "type": [
              "string",
              "null"
            ],
            "description": "The author's LinkedIn page: linkedin.com/in/<handle> for a person, linkedin.com/company/<id> for a company. `null` rather than an /in/<member URN> link, which does not open for anyone."
          },
          "url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Same value as `profileUrl`."
          },
          "linkedinUrl": {
            "type": [
              "string",
              "null"
            ],
            "description": "Same value as `profileUrl`."
          },
          "name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Display name - a person's full name or a company's name."
          },
          "firstName": {
            "type": [
              "string",
              "null"
            ],
            "description": "A person's first name; `null` for a company."
          },
          "lastName": {
            "type": [
              "string",
              "null"
            ],
            "description": "A person's last name; `null` for a company."
          },
          "headline": {
            "type": [
              "string",
              "null"
            ],
            "description": "The author's headline as LinkedIn shows it beside the comment."
          },
          "profilePicture": {
            "type": [
              "string",
              "null"
            ],
            "description": "Avatar or logo URL."
          },
          "entityUrn": {
            "type": [
              "string",
              "null"
            ],
            "description": "A person's member URN (ACoAA...) or a company's URN (urn:li:company:<id> or urn:li:organization:<id>)."
          }
        }
      },
      "PostComment": {
        "type": "object",
        "description": "One comment or reply from POST /api/v1/post/comments or /post/company-comments. Every row has exactly these keys whichever provider served the page; a value that is unknown is `null`, never omitted.",
        "additionalProperties": false,
        "required": [
          "id",
          "url",
          "text",
          "postedAt",
          "postedAtTimestamp",
          "isReply",
          "parentCommentUrn",
          "isEdited",
          "isPinned",
          "totalReactions",
          "totalComments",
          "reactionType",
          "author",
          "comment",
          "commenter"
        ],
        "properties": {
          "id": {
            "type": [
              "string",
              "null"
            ],
            "description": "The comment's URN, `urn:li:comment:(activity:<postId>,<commentId>)` (the parent is `ugcPost:` on some posts). The stable key for a comment: the same comment has the same `id` whichever provider served it, so dedupe on it.",
            "example": "urn:li:comment:(activity:7504308642557345792,7507351720427532345)"
          },
          "url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Opens the comment on LinkedIn: its post's /feed/update/ link with the comment selected (`?commentUrn=`). The provider's own link when the row carries no comment URN."
          },
          "text": {
            "type": [
              "string",
              "null"
            ],
            "description": "The comment's text."
          },
          "postedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the COMMENT was posted (ISO 8601) - never the post's time. The provider's own comment time when it sends one; otherwise the time the comment's ID encodes (`commentId` shifted right by 22 bits is epoch milliseconds). `null` when neither is available or plausible."
          },
          "postedAtTimestamp": {
            "type": [
              "integer",
              "null"
            ],
            "description": "The same instant as `postedAt`, in epoch milliseconds."
          },
          "isReply": {
            "type": "boolean",
            "description": "`true` for a reply to another comment, `false` for a top-level comment."
          },
          "parentCommentUrn": {
            "type": [
              "string",
              "null"
            ],
            "description": "A reply's parent comment `id`; `null` on a top-level comment."
          },
          "isEdited": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Whether the comment was edited; `null` when the provider does not say (the primary provider never does)."
          },
          "isPinned": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Whether the post's author pinned the comment; `null` when the provider does not say."
          },
          "totalReactions": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Reactions on this comment; `null` when the provider sent no count."
          },
          "totalComments": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Replies to this comment; `null` when the provider sent no count."
          },
          "reactionType": {
            "description": "The primary provider's reaction summary for the comment, exactly as it sends it (a reaction name or a per-type count object); `null` from the fallback provider. Prefer `totalReactions`."
          },
          "author": {
            "$ref": "#/components/schemas/PostCommentAuthor"
          },
          "comment": {
            "type": [
              "string",
              "null"
            ],
            "deprecated": true,
            "description": "Deprecated alias of `text`, kept for existing callers."
          },
          "commenter": {
            "$ref": "#/components/schemas/PostCommentAuthor",
            "deprecated": true,
            "description": "Deprecated alias of `author`, kept for existing callers."
          }
        }
      },
      "PostCommentsResult": {
        "type": "object",
        "description": "Completed `result` of a POST /api/v1/post/comments or /post/company-comments job.",
        "required": [
          "data",
          "total",
          "hasMore",
          "source"
        ],
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PostComment"
            },
            "description": "The page's comments, each followed by its replies when `includeReplies` is true."
          },
          "total": {
            "type": [
              "integer",
              "null"
            ],
            "description": "The provider's count of TOP-LEVEL comments on the post - not a row count, since `data` also carries replies. `null` when the provider gave none. Do not page against it."
          },
          "hasMore": {
            "type": "boolean",
            "description": "`true` whenever `data` is non-empty; a full sweep ends on an empty page."
          },
          "source": {
            "type": "string",
            "enum": [
              "primary",
              "fallback"
            ],
            "description": "Which provider served this page. The rows are the same shape either way. A report only: comment requests take no `source`, and each page is served by whichever provider answers it."
          }
        }
      },
      "LinkedInUsername": {
        "type": "string",
        "minLength": 1,
        "description": "LinkedIn personal profile or company `username`, not a full URL.",
        "example": "demo-profile"
      },
      "CampaignStep": {
        "type": "object",
        "required": [
          "type"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "enum": [
              "connection_request",
              "wait_for_connection",
              "send_message",
              "delay",
              "if_replied",
              "condition",
              "ab_test",
              "withdraw",
              "like_post",
              "rotate_sender"
            ],
            "description": "Step type. Anything outside this set is rejected with 400."
          },
          "config": {
            "type": "object",
            "additionalProperties": true,
            "description": "Step config, keyed by step type: connection_request { note }, wait_for_connection (+branches), send_message { message, mode }, delay { amount, unit }, if_replied { withinDays } (+branches), condition { field, operator, value } (+branches), ab_test { split } (+branches), withdraw, like_post, rotate_sender. Keys a step does not use are ignored; omitted keys take the default shown.",
            "properties": {
              "note": {
                "type": "string",
                "description": "connection_request: the connection-request note. Defaults to empty (no note)."
              },
              "message": {
                "type": "string",
                "description": "send_message: the message body. Defaults to empty."
              },
              "mode": {
                "type": "string",
                "enum": [
                  "auto",
                  "manual"
                ],
                "default": "auto",
                "description": "send_message: `manual` makes the send a task a user confirms instead of an automatic send."
              },
              "amount": {
                "type": "integer",
                "minimum": 1,
                "default": 1,
                "description": "delay: how long to wait, in `unit`. Values below 1 are raised to 1."
              },
              "unit": {
                "type": "string",
                "enum": [
                  "hours",
                  "days"
                ],
                "default": "days",
                "description": "delay: the unit for `amount`."
              },
              "withinDays": {
                "type": "integer",
                "minimum": 1,
                "default": 7,
                "description": "if_replied: how long to wait for a reply before taking the no-reply branch."
              },
              "split": {
                "type": "number",
                "minimum": 0,
                "maximum": 100,
                "default": 50,
                "description": "ab_test: percentage sent down the first branch; the second gets the remainder."
              },
              "field": {
                "type": "string",
                "description": "condition: the lead attribute to test. Known values are jobTitle, company, country and email; any other value is passed through to the provider as-is."
              },
              "operator": {
                "type": "string",
                "enum": [
                  "equals",
                  "not_equals",
                  "contains",
                  "exists"
                ],
                "default": "equals",
                "description": "condition: how `field` is compared to `value`. An unrecognised operator falls back to equals."
              },
              "value": {
                "type": "string",
                "description": "condition: the value to compare against. Not used by the `exists` operator."
              }
            }
          },
          "branches": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "label",
                "path"
              ],
              "properties": {
                "id": {
                  "type": "string"
                },
                "label": {
                  "type": "string",
                  "description": "Required, and load-bearing for wait_for_connection and condition: the label selects which branch the path becomes (\"Connected\"/\"Not connected\", \"If\"/\"Else\"). Two labels that resolve to the same branch are rejected with 400. ab_test and if_replied map by position, so their labels are free text."
                },
                "path": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/CampaignStep"
                  }
                }
              }
            },
            "description": "Branch paths for branching steps (ab_test / condition / if_replied / wait_for_connection)."
          }
        }
      },
      "CreditsBalance": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "balance",
          "used",
          "limit",
          "remaining",
          "plan",
          "resetAt",
          "nextReset",
          "spentToday",
          "dailyCeiling",
          "dailyCeilingMode"
        ],
        "properties": {
          "balance": {
            "type": "integer",
            "description": "Remaining enriching credits (same value as remaining; there is no separate top-up wallet)."
          },
          "used": {
            "type": "integer",
            "description": "Enriching credits consumed in the current billing period."
          },
          "limit": {
            "type": "integer",
            "description": "Monthly enriching-credit allowance for the team's plan."
          },
          "remaining": {
            "type": "integer",
            "description": "limit \u2212 used, never negative."
          },
          "plan": {
            "type": "string",
            "description": "The team's plan id (e.g. trial, starter, growth, pro, scale)."
          },
          "resetAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Start of the current billing period."
          },
          "nextReset": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When used resets to 0 (resetAt + 1 month)."
          },
          "spentToday": {
            "type": "integer",
            "description": "What this team's KEYWORD SEARCHES have spent so far today, over the UTC calendar day \u2014 the number `dailyCeiling` is enforced against, and the one a \"today\" bar sits beside the monthly used/limit bar to show. \u26a0 NOT A SUBSET OF `used` AT THE MOMENT YOU READ IT: a keyword sweep spends when it writes a lead row, while `used` counts ENRICHMENT charges, which land minutes to hours later as the enrichment poller reaches those leads. The two converge \u2014 they are the same money measured at two moments."
          },
          "dailyCeiling": {
            "type": [
              "integer",
              "null"
            ],
            "description": "The most this team's keyword searches may spend between them in one day, or `null` for NO CEILING. A run that would cross it stops AT it (a partial run, not a refusal) and reports `lastRun.stoppedBy` `team_cap` with a reason naming the ceiling and the day's spend; the day's remaining searches are skipped with the same marker and run again tomorrow. `null` is an ANSWER, not a missing value \u2014 read `dailyCeilingMode` to learn which answer."
          },
          "dailyCeilingMode": {
            "type": "string",
            "enum": [
              "default",
              "none",
              "custom"
            ],
            "description": "How the ceiling was decided. `default` \u2014 nobody chose, so there is no ceiling and `dailyCeiling` is null: Cornersight never sets one. On a free trial there is none whatever is stored. `none` \u2014 an admin deliberately turned the ceiling off, and `dailyCeiling` is null. `custom` \u2014 an admin set the number. `default` and `none` are DIFFERENT STATES on purpose: the first is a team that has not decided, the second a team that decided not to. Changed in the dashboard under Settings; owners and admins only."
          }
        }
      },
      "CreditsUsage": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "from",
          "to",
          "totalCharged",
          "bySource",
          "byDate",
          "bySourceId"
        ],
        "properties": {
          "from": {
            "type": "string",
            "format": "date-time",
            "description": "Start of the reported window (inclusive)."
          },
          "to": {
            "type": "string",
            "format": "date-time",
            "description": "End of the reported window (exclusive)."
          },
          "totalCharged": {
            "type": "integer",
            "description": "Total enriching credits charged in the window."
          },
          "bySource": {
            "type": "array",
            "description": "Credits charged grouped by charge source, most to least.",
            "items": {
              "type": "object",
              "required": [
                "source",
                "credits"
              ],
              "properties": {
                "source": {
                  "type": "string",
                  "description": "Charge source, e.g. api_enrich_profile (Public API) or worker_sync (automated sync); unknown for legacy charges."
                },
                "credits": {
                  "type": "integer"
                }
              }
            }
          },
          "byDate": {
            "type": "array",
            "description": "Credits charged grouped by UTC calendar day, ascending.",
            "items": {
              "type": "object",
              "required": [
                "date",
                "credits"
              ],
              "properties": {
                "date": {
                  "type": "string",
                  "description": "UTC calendar day (YYYY-MM-DD)."
                },
                "credits": {
                  "type": "integer"
                }
              }
            }
          },
          "bySourceId": {
            "type": "array",
            "description": "Credits charged grouped by the tracked SOURCE they were spent on, most to least. THIS IS THE FIELD THAT MAKES THE LEDGER RECONCILE: summing it always reproduces `totalCharged`, including for sources whose leads are no longer readable. Untracking is a soft delete, so a source untracked inside the window still appears here \u2014 named, and marked `status: \"inactive\"` \u2014 even though GET /api/v1/leads no longer returns its leads and `bySource` only ever said which SYSTEM charged. A spike of credits with no matching leads is normally one of these rows.",
            "items": {
              "type": "object",
              "required": [
                "sourceId",
                "username",
                "type",
                "status",
                "credits"
              ],
              "properties": {
                "sourceId": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "The tracked source's id \u2014 the same id GET /api/v1/sources reports and GET /api/v1/leads filters by as profileId. NULL for charges that can no longer be attributed: the source row was genuinely deleted (the ledger keeps the charge and drops the pointer) or the charge predates the ledger recording one. Present as an explicit null bucket rather than omitted, so the array still sums to totalCharged."
                },
                "username": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "The source's LinkedIn handle, post URN, or keyword text. NULL when sourceId is NULL."
                },
                "type": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "person, company, post or keyword. NULL when sourceId is NULL."
                },
                "status": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "The source's current status. `inactive` means it was untracked, which is the usual explanation for credits whose leads cannot be seen. NULL when sourceId is NULL."
                },
                "credits": {
                  "type": "integer"
                }
              }
            }
          }
        }
      },
      "CompanyEnrichmentResult": {
        "type": "object",
        "description": "Completed `result` of a company enrichment job. Fields are FLAT on `result` \u2014 the same envelope profile enrichment uses, so clients can share a result type. Only fields the upstream source returns are present, so apart from `name` every field is optional.",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "description": "Display name, e.g. \"Instantly.ai\"."
          },
          "universalName": {
            "type": "string",
            "description": "LinkedIn slug, e.g. \"instantlyapp\"."
          },
          "url": {
            "type": "string",
            "description": "Canonical LinkedIn company URL."
          },
          "objectUrn": {
            "type": [
              "integer",
              "string"
            ],
            "description": "Stable LinkedIn organization id, e.g. 1035."
          },
          "entityUrn": {
            "type": "string",
            "description": "Canonical URN derived from objectUrn, e.g. \"urn:li:organization:1035\". THIS IS THE VALUE THE KEYWORD TARGETING FILTERS TAKE: pass it straight to `authorCompany`, `fromCompany` or `mentionsCompany` on POST /api/v1/keyword/track. The bare `objectUrn` beside it is accepted too and is stored as this wrapped form."
          },
          "type": {
            "type": "string",
            "description": "Company type, e.g. \"Privately Held\", \"Public Company\"."
          },
          "description": {
            "type": "string",
            "description": "The company About text."
          },
          "tagline": {
            "type": "string"
          },
          "website": {
            "type": "string"
          },
          "phone": {
            "type": "string"
          },
          "followerCount": {
            "type": "integer",
            "description": "LinkedIn follower count, e.g. 28246299."
          },
          "foundedYear": {
            "type": "integer",
            "description": "Founding year. Frequently absent \u2014 the source returns null for many companies, including large ones."
          },
          "industry": {
            "type": "string",
            "description": "Primary industry (first of `industries`), e.g. \"Software Development\"."
          },
          "industries": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "specialties": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Normalized from the source's \"specialities\" field; absent when the source has none."
          },
          "employeeCount": {
            "type": "integer",
            "description": "Exact headcount reported by the source, e.g. 231622."
          },
          "staffCountRange": {
            "type": "string",
            "description": "Bucketed headcount, e.g. \"51-200\", \"10001+\". Can differ from employeeCount: the range is the LinkedIn size bucket, employeeCount the reported total."
          },
          "headquarters": {
            "type": "object",
            "description": "Headquarters address; individual parts appear only when the source has them.",
            "properties": {
              "country": {
                "type": "string"
              },
              "geographicArea": {
                "type": "string"
              },
              "city": {
                "type": "string"
              },
              "postalCode": {
                "type": "string"
              },
              "line1": {
                "type": "string"
              },
              "line2": {
                "type": "string"
              }
            }
          },
          "images": {
            "type": "object",
            "description": "Company imagery (lowercase `images` \u2014 the legacy capital-I `Images` envelope is gone).",
            "properties": {
              "logo": {
                "type": "string"
              },
              "cover": {
                "type": "string"
              }
            }
          },
          "creditsCharged": {
            "type": "integer",
            "description": "Always 0 \u2014 company enrichment never consumes an enriching credit."
          }
        },
        "example": {
          "name": "Instantly.ai",
          "universalName": "instantlyapp",
          "url": "https://www.linkedin.com/company/instantlyapp",
          "objectUrn": 79083508,
          "entityUrn": "urn:li:organization:79083508",
          "type": "Privately Held",
          "description": "All-in-one outreach platform for business growth.",
          "tagline": "Instantly connects you to 450M+ leads and automates outreach for 700K+ businesses worldwide.",
          "website": "http://www.instantly.ai/",
          "followerCount": 61200,
          "industry": "Software Development",
          "industries": [
            "Software Development"
          ],
          "employeeCount": 278,
          "staffCountRange": "51-200",
          "headquarters": {
            "country": "US",
            "geographicArea": "WY",
            "city": "Sheridan",
            "postalCode": "82801"
          },
          "images": {
            "logo": "https://media.licdn.com/dms/image/.../instantlyapp_logo",
            "cover": "https://media.licdn.com/dms/image/.../instantlyapp_cover"
          },
          "creditsCharged": 0
        }
      },
      "Lead": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "profileId": {
            "type": "string",
            "format": "uuid",
            "description": "Tracked profile whose post this engagement came from."
          },
          "name": {
            "type": "string",
            "nullable": true
          },
          "linkedinUsername": {
            "type": "string",
            "nullable": true,
            "description": "The public vanity handle, or null. NEVER a member URN \u2014 presentLeadIdentity splits the stored key and puts the URN in `linkedinUrn` (worker/src/lead-identity.ts). Capture keys a handle-less engager's lead on their member URN, but this endpoint returns ENRICHED leads only and enrichment resolves the public handle from that URN first, so a lead you can see almost always carries a real handle. Measured 2026-09-08: of the 3,265 enriched leads captured since URN-keyed capture shipped (2026-09-07), ZERO kept the URN. The null population is real but small and historical \u2014 6,651 leads, 2.4% of the 277,054 enriched fleet-wide, every one captured before 2026-09-07 \u2014 and concentrated: 71 sources hold them and one is 70% URN-keyed. A NULL THEREFORE MEANS ENRICHMENT COULD NOT RESOLVE A HANDLE, not that the engager had none \u2014 fall back to `linkedinUrn` rather than discarding the row."
          },
          "linkedinUrn": {
            "type": "string",
            "nullable": true,
            "example": "urn:li:fsd_profile:ACoAAB1a2b3c4d5e6f",
            "description": "The lead's LinkedIn member URN, when one is known. Populated at capture or learned during enrichment. It is the STABLE identity: a handle-less engager's lead is keyed on this at capture, and enrichment normally replaces that key with the resolved handle \u2014 `linkedinUsername` stays null only where it could not, which is the one case a consumer must fall back to this field for. It is also what GET /api/v1/engagers groups on, which is why that endpoint reports one row per person across both spellings."
          },
          "linkedinUrl": {
            "type": "string",
            "nullable": true,
            "description": "Built from whatever identity keys the lead \u2014 normally the resolved vanity handle, so normally a working profile URL. It reads https://www.linkedin.com/in/ACoAAB... , which does NOT resolve publicly, only on the ~2.4% of enriched leads whose handle enrichment could not resolve. The test is `linkedinUsername === null`, NOT a prefix check on `linkedinUsername`, which is never a URN here. Treat it as canonical for CRM matching, dedup keys and click-through only when `linkedinUsername` is non-null."
          },
          "avatarUrl": {
            "type": "string",
            "nullable": true
          },
          "jobTitle": {
            "type": "string",
            "nullable": true
          },
          "company": {
            "type": "string",
            "nullable": true
          },
          "companyName": {
            "type": "string",
            "nullable": true,
            "description": "Company name; same value as company."
          },
          "companyDomain": {
            "type": "string",
            "nullable": true,
            "description": "Company website hostname, without a scheme or path."
          },
          "companyUrl": {
            "type": "string",
            "nullable": true,
            "description": "companyUrl is the company's own website, from the website field of its company record, and never a LinkedIn URL; companyLinkedinUrl is its LinkedIn company page. Null when the company record has no website. companyDomain is the same website's hostname. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits."
          },
          "companyDescription": {
            "type": "string",
            "nullable": true,
            "description": "Company description (its tagline when it has no description). companyDescription and companyLocation (headquarters) come from the company record, resolved once per company and cached for 6 months, at no enriching-credit cost."
          },
          "companyLocation": {
            "type": "string",
            "nullable": true,
            "description": "Company headquarters location, as City, Region, Country. companyDescription and companyLocation (headquarters) come from the company record, resolved once per company and cached for 6 months, at no enriching-credit cost."
          },
          "companyLinkedinUrl": {
            "type": "string",
            "nullable": true,
            "description": "The employer's LinkedIn company page, read from the person's current position (the company record fills it only when that is missing), or null. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits."
          },
          "companyIndustry": {
            "type": "string",
            "nullable": true,
            "description": "The employer's industry, or null. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits."
          },
          "companyEmployeeCount": {
            "type": "integer",
            "nullable": true,
            "description": "The employer's reported total employee count, or null (never an invented zero). Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits. companyStaffRange is the LinkedIn size bucket and companyEmployeeCount is the reported total, so the two can disagree."
          },
          "companyStaffRange": {
            "type": "string",
            "nullable": true,
            "description": "The employer's LinkedIn size bucket, for example \"51-200\", or null. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits. companyStaffRange is the LinkedIn size bucket and companyEmployeeCount is the reported total, so the two can disagree."
          },
          "companyEnrichedAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "When this lead's company record was resolved (ISO 8601), or null. companyEnrichedAt is null until the company has been resolved; after that, a null company field means the company record has no value for it. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits."
          },
          "country": {
            "type": "string",
            "nullable": true
          },
          "engagementType": {
            "type": "string",
            "enum": [
              "Like",
              "Comment",
              "Author"
            ],
            "description": "How the person engaged with the post: `Like`, `Comment`, or `Author` — the person who WROTE a post a keyword search kept, captured when that search has `capturePostAuthors` on. An Author lead is charged like an engager (one credit per new person for the search, repeats free), and the same person can hold an Author row and a Like or Comment row on one post: two rows, one credit."
          },
          "commentText": {
            "type": "string",
            "nullable": true,
            "description": "Comment body when engagementType is Comment."
          },
          "commentPostedAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "When the COMMENT itself was posted, ISO 8601 - the comment's own time, not the post's (that is postPostedAt). Always present. Null for Likes. On a Comment lead it is the data provider's comment time when one was sent (the fallback provider sends it), otherwise the time the comment's own LinkedIn ID encodes (see COMMENT TIME on POST /api/v1/post/comments) - on every capture path, keyword-search Comment leads included. Null only when neither was available, which can be the case on Comment leads captured before comment times were decoded. Never filled from the post's time."
          },
          "isIcp": {
            "type": "boolean",
            "description": "Whether the lead matched the profile's ICP filter rules."
          },
          "webhookStatus": {
            "type": "string",
            "enum": [
              "pending",
              "sent",
              "failed",
              "no_webhook"
            ]
          },
          "postUrl": {
            "type": "string",
            "nullable": true,
            "description": "URL of the post that was engaged with."
          },
          "postPostedAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "When the POST this lead engaged with was published - the post's time, not the comment's. On a Comment lead the comment's own time is commentPostedAt."
          },
          "detectedAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "createdAt": {
            "type": "string",
            "format": "date-time",
            "description": "When Cornersight stored the lead. Use with since/until for incremental pulls."
          }
        }
      },
      "LeadList": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Lead"
            }
          },
          "total": {
            "type": "integer",
            "description": "Exact count of leads matching the filters, ignoring limit/offset. Counts ENGAGEMENTS, not unique people - see the endpoint description. Page with `hasMore` rather than by dividing this number: `hasMore` is derived from the rows actually returned."
          },
          "limit": {
            "type": "integer"
          },
          "offset": {
            "type": "integer"
          },
          "hasMore": {
            "type": "boolean",
            "description": "True when more pages remain after this one."
          },
          "pendingEnrichment": {
            "type": "integer",
            "description": "Leads captured for this scope that are still awaiting enrichment, and are therefore NOT in `data` and NOT counted in `total`. WARNING: it is not \"how many returned rows lack company/title\" \u2014 this endpoint returns enriched leads only, so every row in `data` already has its firmographics. This number is what the response is NOT showing you, which is why `total` alone can look lower than the leads you know were captured. Scope matches the dashboard's counter: team plus the same profile scoping (profileId when given, otherwise your visible sources), and it deliberately ignores engagementType/webhookStatus/isIcp/since/until and the text filters \u2014 those read fields a pending lead does not have yet. Always present; 0 means nothing is waiting. Enrichment is GATED: it only runs for a team whose subscription is active (or a live trial) and which has enriching credits. A team that is cancelled, blocked or out of credits accumulates pending leads INDEFINITELY \u2014 measured in production, one cancelled team holds 54,947 leads that have never been attempted, the oldest waiting 51 days. So a large pendingEnrichment means enrichment is gated for that team, NOT that the queue is stuck. Check GET /api/v1/credits."
          }
        }
      },
      "RawLead": {
        "type": "object",
        "description": "One engagement captured by a source in raw mode, exactly as capture recorded it. Never enriched.",
        "additionalProperties": false,
        "required": [
          "id",
          "sourceId",
          "linkedinUrl",
          "linkedinUrn",
          "name",
          "action",
          "commentText",
          "post",
          "detectedAt"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "The lead's id."
          },
          "sourceId": {
            "type": "string",
            "format": "uuid",
            "description": "The tracked source that captured the lead \u2014 the `id` GET /api/v1/sources reports, and the value `profileId` scopes by."
          },
          "linkedinUrl": {
            "type": "string",
            "description": "The person's LinkedIn profile URL as captured. For someone captured without a public handle it is built from their member id (https://www.linkedin.com/in/ACoAA\u2026), which does not resolve publicly; `linkedinUrn` then carries that id."
          },
          "linkedinUrn": {
            "type": [
              "string",
              "null"
            ],
            "description": "The person's LinkedIn member URN (ACoAA\u2026) when one is known, else null. A handle is never reported here, and this id is never reported as a name."
          },
          "name": {
            "type": [
              "string",
              "null"
            ],
            "description": "The person's name as captured, or null when capture recorded only an identifier \u2014 a value equal to the handle or the URN, or one that looks like a member URN (ACoAA\u2026 / urn:li:\u2026). An id is never returned as a name. Raw leads are never enriched, so a null stays null."
          },
          "action": {
            "type": "string",
            "enum": [
              "Like",
              "Comment",
              "Author"
            ],
            "description": "How the person engaged: `Like`, `Comment`, or `Author` \u2014 the person who WROTE a post a keyword search kept, captured when that search has `capturePostAuthors` on."
          },
          "commentText": {
            "type": [
              "string",
              "null"
            ],
            "description": "The comment body when action is Comment, else null."
          },
          "post": {
            "type": "object",
            "description": "The post that was engaged with.",
            "additionalProperties": false,
            "required": [
              "url",
              "urn",
              "postedAt"
            ],
            "properties": {
              "url": {
                "type": "string",
                "description": "The post's LinkedIn URL."
              },
              "urn": {
                "type": "string",
                "description": "The post's LinkedIn URN, e.g. urn:li:activity:7300000000000000000."
              },
              "postedAt": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time",
                "description": "When the post was published, or null when it is not known."
              }
            }
          },
          "detectedAt": {
            "type": "string",
            "format": "date-time",
            "description": "When the engagement was captured. Rows are ordered by it (newest first) and since/until compare against it."
          }
        }
      },
      "RawLeadList": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "data",
          "total",
          "limit",
          "offset",
          "hasMore"
        ],
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/RawLead"
            }
          },
          "total": {
            "type": "integer",
            "description": "Exact count of raw leads matching the filters, ignoring limit/offset. Counts ENGAGEMENTS, not unique people: a person who liked and commented is two rows. Page with `hasMore`, not by dividing this number."
          },
          "limit": {
            "type": "integer"
          },
          "offset": {
            "type": "integer"
          },
          "hasMore": {
            "type": "boolean",
            "description": "True when more pages remain after this one. Derived from the rows actually fetched, not from `total`."
          }
        }
      },
      "TrackPostRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "postUrl"
        ],
        "properties": {
          "captureReplies": {
            "type": "boolean",
            "default": true,
            "description": "Whether this tracked post captures reply authors as leads. False skips replies before lead writes and credits; omission preserves the stored setting when re-tracking."
          },
          "enrichLeads": {
            "type": "boolean",
            "default": true,
            "description": "Whether this source's leads are enriched. Default true, what every source has always done. RAW MODE when false: this source's engagers are still captured and charged exactly as before \u2014 one credit per NEW person per source, repeats free, the same ledger and caps \u2014 but NEVER enriched: no job title, company or country, and no enrichment provider call. Those leads end enrichment status `raw` and are read with GET /api/v1/leads/raw; they never appear in GET /api/v1/leads, /engagers, exports, webhooks or integrations. A plain setting, not a spend change: the price is the same either way, so it never needs `confirmSpend`. It applies to leads captured or processed AFTER the change \u2014 leads already enriched stay enriched and raw leads stay raw. Omission preserves the stored setting when re-tracking; re-tracking an active post with it changes the setting without another capture."
          },
          "creditCapPerSync": {
            "type": "integer",
            "format": "int32",
            "minimum": 1,
            "maximum": 2147483647,
            "nullable": true,
            "description": "The most credits ONE SYNC of this tracked source may spend \u2014 CAPTURE CAP COUNTS LEAD ROWS; BILLING CHARGES ONCE PER NEW PERSON PER SOURCE (tracked_profiles.credit_cap, migration 147). PER SYNC AND NOT A LIFETIME TOTAL: the source re-syncs on its own about every 24 hours and this bounds EACH of those runs. PER SYNC, EVERY SYNC: it bounds EACH sync of that one person, company page or post \u2014 the capture cap counts lead rows, while billing charges once per new person per source \u2014 not the first pull only and not the life of the source, and there is NO ESTIMATE, NO CONFIRMATION GATE AND NO TEAM CEILING behind it: nothing prices a sync before you set the cap, raising or lowering it never needs `confirmSpend`, and the team's `dailyCeiling` does not count a credit of it. \u26a0\ufe0f A KEYWORD SEARCH'S `creditCap` IS THE OTHER FIELD AND HAS ALL THREE: it is the daily bound on a RECURRING SWEEP, POST /api/v1/keyword/estimate prices it as `estimatedDailyMax`, `confirmSpend` gates a create or a raise with a `409 spend_confirmation_required`, and the team's `dailyCeiling` stops it \u2014 and `dailyCeiling` COUNTS KEYWORD SPEND ONLY, so no number of profile syncs can ever reach it. \u26a0 RENAMED from `creditCap` in 4.0.0 and NOT aliased: `creditCap` now names ONLY a keyword search's per-RUN cap, a different number on a different table, and sending it here is a 400 carrying `code: \"renamed_field\"`. A whole number from 1 to 2147483647, or `null` for NO LIMIT, which is what every source without one is. OMITTED LEAVES THE STORED VALUE ALONE; `null` CLEARS IT. It binds EVERY sync, not the first pull. AND IT IS HOW THE LIMIT IS CHANGED: calling this endpoint again for a source the team already tracks applies the value it carries, which is the only way to edit the setting over this API \u2014 the same \"a second create resumes the source and applies the settings it carries\" rule POST /api/v1/keyword/track follows. 0 is REFUSED rather than stored (a source that syncs and writes nothing is a PAUSED source, and `status` already says that), as are fractions and values past int4 \u2014 the worker resolves those to \"no cap\", so storing one would show a limit that binds nothing."
          },
          "postUrl": {
            "type": "string",
            "minLength": 1,
            "description": "URL or URN of the LinkedIn post to track. Three forms are accepted, and all three are verified against LinkedIn before the source is created:\n\n1. `https://www.linkedin.com/feed/update/urn:li:activity:<id>` \u2014 what you get by copying a post's link from your feed. Accepted with an `activity` or `ugcPost` URN.\n2. `https://www.linkedin.com/posts/<slug>-<id>-<hash>` \u2014 the permalink from a post's share menu. This form is RESOLVED against LinkedIn and the URN that comes back is what identifies the source, because the id in a permalink's slug can be a *share* id \u2014 a different number from the post's activity id.\n3. `urn:li:activity:<id>` or `urn:li:ugcPost:<id>` \u2014 a bare URN on its own.\n\nA `urn:li:share:<id>` URN is REJECTED in every form, bare or /feed/update/, with a 400 telling you to paste the permalink instead. A share id cannot be mapped to its activity id without the permalink, so storing one would create a source that silently captures nothing.\n\nThe `url` this source reports back from GET /api/v1/sources is stored EXACTLY AS SENT when the post is FIRST tracked \u2014 send a /feed/update/ URL and you get a /feed/update/ URL back; it is never normalised. Re-tracking a post the team already has returns the EXISTING source unchanged in identity and URL but applies any named capture setting, so its stored url keeps whichever form was used the first time even if you now send a different one. Note that the 201 body echoes the `postUrl` YOU sent, which on a duplicate is not necessarily the url stored on the source. Only the URN that identifies the post is normalised.",
            "example": "https://www.linkedin.com/posts/some-person_a-post-slug-activity-0000000000000000000-AbCd"
          }
        }
      },
      "TrackPostResponse": {
        "type": "object",
        "properties": {
          "captureReplies": {
            "type": "boolean",
            "description": "Echoed only when the request named it; the reply-capture setting stored on this tracked post."
          },
          "enrichLeads": {
            "type": "boolean",
            "description": "Echoed only when the request named it; the raw-mode setting now stored on this tracked post (false = raw: captured and charged, never enriched, read with GET /api/v1/leads/raw)."
          },
          "creditCapPerSync": {
            "type": [
              "integer",
              "null"
            ],
            "description": "ECHOED ONLY WHEN THE REQUEST NAMED IT \u2014 the per-sync limit now stored on this tracked post. Absent when the body carried none, which is the shape this endpoint has always returned. Per sync, every sync \u2014 the capture cap counts lead rows, while billing charges once per new person per source, with no estimate, no confirmation gate and no team ceiling: the team's `dailyCeiling` counts KEYWORD spend only. Not a keyword search's `creditCap`, which is the daily bound on a recurring sweep and has all three."
          },
          "postUrn": {
            "type": "string",
            "description": "The RESOLVED activity URN now tracked. May differ from the id in the submitted URL.",
            "example": "urn:li:activity:0000000000000000000"
          },
          "postUrl": {
            "type": "string",
            "description": "The permalink as submitted, stored verbatim."
          },
          "syncId": {
            "type": [
              "string",
              "null"
            ],
            "description": "Id of the first engagement capture queued for the post, or `null` when none was queued: the team's plan does not allow capture, or the post was already tracked and has synced before \u2014 re-tracking does not re-sync, and `syncNotQueuedReason` says so. POST /api/v1/sources/{id}/sync is the on-demand sync."
          },
          "syncNotQueuedReason": {
            "type": "string",
            "description": "PRESENT ONLY when this call re-tracked a post that has already synced, beside `syncId: null`: a sentence saying no sync was queued, that the source runs on its daily schedule, and that any setting sent with the call applies from that run, and naming POST /api/v1/sources/{id}/sync, which syncs it now. Absent on a create and on a reactivation, which do queue a capture."
          }
        }
      },
      "TrackKeywordRequest": {
        "type": "object",
        "additionalProperties": false,
        "description": "EXACTLY ONE OF `keywords` OR `expression` IS REQUIRED, which is why neither is listed under `required`: OpenAPI cannot express \"one of these two\", and declaring `keywords` required would tell a reader to send a field this endpoint refuses alongside their `expression`. Sending both is a 400; sending neither is a 400.",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "description": "What this search is CALLED in your source list, e.g. \"hiring an SDR\". Optional: omit it and the search is titled by its keywords, which is what every search created before this field existed shows. Blank is rejected rather than treated as absent \u2014 omit the field instead. The name is a label only: the KEYWORDS remain the search's identity, so two searches may share a name but not a term-set."
          },
          "captureReplies": {
            "type": "boolean",
            "default": true,
            "description": "Whether this tracked search captures reply authors as leads. False skips replies before lead writes and credits."
          },
          "enrichLeads": {
            "type": "boolean",
            "default": true,
            "description": "Whether this source's leads are enriched. Default true, what every source has always done. RAW MODE when false: this source's engagers are still captured and charged exactly as before \u2014 one credit per NEW person per source, repeats free, the same ledger and caps \u2014 but NEVER enriched: no job title, company or country, and no enrichment provider call. Those leads end enrichment status `raw` and are read with GET /api/v1/leads/raw; they never appear in GET /api/v1/leads, /engagers, exports, webhooks or integrations. A plain setting, not a spend change: the price is the same either way, so it never needs `confirmSpend`. It applies to leads captured or processed AFTER the change \u2014 leads already enriched stay enriched and raw leads stay raw. Stored on the search's tracked source. A resume (the same keywords again) with it changes the setting; omitting it leaves the stored value alone. It does not change the estimate: raw mode costs what enrichment costs."
          },
          "keywords": {
            "type": "array",
            "minItems": 1,
            "maxItems": 10,
            "items": {
              "type": "string",
              "minLength": 1,
              "maxLength": 200
            },
            "description": "One to ten search terms. EACH IS A SEPARATE PROVIDER CALL per run \u2014 the underlying search takes a single term and supports no OR syntax \u2014 and the results are merged and deduplicated by post URN, so a post two terms both find is captured once. The run's scan (at most 2,000 posts, an internal bound) is SHARED across the terms and allocated round-robin, so a high-volume term cannot crowd out the others; a term that runs out early yields its share to the rest. Terms must be unique, and a term containing a parenthesis is a 400 (the search provider refuses one). OPTIONAL SINCE THE BOOLEAN EXPRESSION FIELD: send `keywords` or `expression`, never both. A plain list is identical to the same terms joined with OR. A term wrapped in double quotes (straight or curly) is an EXACT PHRASE, as it is in `expression`: `\"outbound playbook\"` keeps only posts whose text has those words next to each other and in order, and discards the rest with the reason `missing phrase \"outbound playbook\"`. It is stored in straight quotes. An unquoted multi-word term stays a broad match.",
            "example": [
              "AI agents",
              "LLM evals",
              "RAG pipelines"
            ]
          },
          "keyword": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "deprecated": true,
            "description": "Convenience alias for a single-term search. Accepted as a one-element `keywords`. Prefer `keywords`."
          },
          "expression": {
            "type": "string",
            "minLength": 1,
            "maxLength": 1000,
            "description": "A boolean expression INSTEAD OF a term list \u2014 `hiring AND \"sales ops\" NOT recruiter OR fundraising`. Exactly one of `keywords` or `expression` may be sent; both together is a 400, neither is a 400. GRAMMAR: NOT binds tighter than AND, which binds tighter than OR, and there are NO PARENTHESES \u2014 `a AND b OR c` means `(a AND b) OR c`. A PARENTHESIS IS A 400 that explains this precedence, with ONE exception: a search's own canonical `expression` (as the create reply and GET /api/v1/sources return it, e.g. `(hiring AND NOT recruiter) OR fundraising`) may be sent back UNCHANGED and compiles to the same search, so a search can be recreated from what it reports. Anything else bracketed \u2014 `(a OR b) AND c`, `NOT (a AND b)`, a stray `(` \u2014 is refused rather than guessed at, and NO TERM MAY CONTAIN A PARENTHESIS, even in quotes: the search provider refuses one. Operators are UPPER CASE ONLY: a lower-case `and` is an ordinary search term, which is what stops an existing term like `sales and marketing` changing meaning. Two terms with no operator between them means OR, which is exactly what a `keywords` list has always meant \u2014 so `{\"expression\": \"a OR b\"}` and `{\"keywords\": [\"a\",\"b\"]}` create the identical search. A quoted phrase is one term; curly quotes count. AND A QUOTED PHRASE IS MATCHED AS A PHRASE in every expression shape, including a lone term and an OR-only branch. Its words must appear next to each other in order, ignoring case and allowing punctuation between them; a post with sales, finance, ops fails the quoted sales ops check. LinkedIn still receives one provider search for each required term and may return broader candidates, which the worker discards before the run's scan ceiling is applied. Unquoted terms and legacy keyword-list chips keep their broad provider matching. COST: only OR is served by the search itself \u2014 each required term is one provider call per run, the same as a `keywords` list of the same terms. AND and NOT CANNOT be expressed upstream (the provider takes one `keyword` string and documents no boolean syntax) and are applied afterwards by reading each post's own text, so a narrow expression spends the same provider searches and keeps fewer posts. The run applies them BEFORE its scan ceiling is allocated, so that ceiling counts posts that survived, and credits are only charged at capture \u2014 a discarded post costs no credits and IS marked as seen, so the next run does not re-harvest it. WHAT WAS DISCARDED IS REPORTED, not merely counted: `lastRun.discardedByExpression` (and its older name `lastRun.postsFilteredOut`) is how many, those posts are counted in `lastRun.postsScanned` and not in `lastRun.postsKept`, and GET /api/v1/sources/{id}/kept-posts?include=swept returns a row per discarded post carrying `outcome: \"discarded\"` and a `reason` naming the failing part of the expression, e.g. `missing phrase \"sales ops\"` or `contains recruiter`. The expression COMPILES to `keywords`: its required terms, de-duplicated, in first-appearance order, and that is what the reply and GET /api/v1/sources report. A malformed expression is a 400 at SAVE TIME, naming the token at fault; it is never stored. Max 10 required terms, 60 tokens, 1000 characters. CREATE-ONLY: PATCH /api/v1/keyword/{id} refuses it, because the seen-set is keyed to the SEARCH and a new expression would inherit the posts the old one rejected.",
            "example": "hiring AND \"sales ops\" NOT recruiter OR fundraising"
          },
          "sort": {
            "type": "string",
            "enum": [
              "RELEVANCE",
              "DATE_POSTED"
            ],
            "default": "DATE_POSTED",
            "description": "\u26a0\ufe0f Stored and reported back, but every keyword search currently RUNS BY RELEVANCE whatever this holds (since 5 October 2026: newest-first returned nothing for high-volume terms)."
          },
          "datePosted": {
            "type": "string",
            "enum": [
              "PAST_24_HOURS",
              "PAST_WEEK",
              "PAST_MONTH"
            ],
            "default": "PAST_WEEK",
            "description": "How far back each sweep looks. PAST_MONTH is the provider's widest window."
          },
          "contentType": {
            "type": "string",
            "enum": [
              "VIDEO",
              "IMAGE",
              "JOB",
              "LIVE_VIDEO",
              "DOCUMENT",
              "COLLABORATIVE_ARTICLE"
            ],
            "description": "Omit for no content-type filter."
          },
          "authorIndustry": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Keeps only posts whose author is in one of these industries. Applied by LinkedIn's own search, so the run's scan is spent on matching posts rather than on posts discarded afterwards. A NUMERIC LinkedIn industry id. Send it bare (`96`) or wrapped (`urn:li:industry:96`); both are accepted and stored as the wrapped form. An industry NAME is not an id. FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN \u2014 `\"jasonlemkin\"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run."
          },
          "authorCompany": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Keeps only posts whose author currently works at one of these companies. A NUMERIC LinkedIn organisation id. Send it bare (`1441`) or wrapped (`urn:li:organization:1441`); both are accepted and stored as the wrapped form. A company NAME or slug is not an id. FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN \u2014 `\"jasonlemkin\"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run."
          },
          "authorKeyword": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Plain words matched against the AUTHOR (headline, job title), not the post text. The only filter in this group that takes words rather than URNs."
          },
          "fromPerson": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Keeps only posts written by these people. A LinkedIn MEMBER ID \u2014 `\"AC\"` followed by base64url characters, about 39 in all. Send it bare (`ACoAAA5eqCEBzmkNfjyOp9_MseBpRQ-P17SuIos`) or wrapped (`urn:li:person:ACoAAA5eqCEBzmkNfjyOp9_MseBpRQ-P17SuIos`); both are accepted and stored as the wrapped form, so `GET /api/v1/sources` reports one spelling whichever you sent. This is the SAME id profile enrichment returns as `entityUrn`. No member id to hand? `GET /api/v1/profile/{username}/urn` resolves any public handle for free \u2014 no enrichment, no tracking, `creditsCharged: 0` (CLI `profile-urn`, MCP `get_profile_urn`). FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN \u2014 `\"jasonlemkin\"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run."
          },
          "fromCompany": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Keeps only posts published by these company pages. A NUMERIC LinkedIn organisation id. Send it bare (`1441`) or wrapped (`urn:li:organization:1441`); both are accepted and stored as the wrapped form. A company NAME or slug is not an id. FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN \u2014 `\"jasonlemkin\"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run."
          },
          "mentionsPerson": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Keeps only posts that @mention one of these people. A LinkedIn MEMBER ID \u2014 `\"AC\"` followed by base64url characters, about 39 in all. Send it bare (`ACoAAA5eqCEBzmkNfjyOp9_MseBpRQ-P17SuIos`) or wrapped (`urn:li:person:ACoAAA5eqCEBzmkNfjyOp9_MseBpRQ-P17SuIos`); both are accepted and stored as the wrapped form, so `GET /api/v1/sources` reports one spelling whichever you sent. This is the SAME id profile enrichment returns as `entityUrn`. No member id to hand? `GET /api/v1/profile/{username}/urn` resolves any public handle for free \u2014 no enrichment, no tracking, `creditsCharged: 0` (CLI `profile-urn`, MCP `get_profile_urn`). FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN \u2014 `\"jasonlemkin\"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run."
          },
          "mentionsCompany": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Keeps only posts that @mention one of these company pages. A NUMERIC LinkedIn organisation id. Send it bare (`1441`) or wrapped (`urn:li:organization:1441`); both are accepted and stored as the wrapped form. A company NAME or slug is not an id. FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN \u2014 `\"jasonlemkin\"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run."
          },
          "aiProvider": {
            "type": "string",
            "enum": [
              "openai",
              "grok",
              "gemini",
              "claude"
            ],
            "description": "WHICH stored credential filters the posts. The KEY ITSELF IS NOT ACCEPTED HERE and cannot be supplied through this API \u2014 save it once in the dashboard's AI filtering panel on Keyword Engagement, where it is held in an encrypted vault, one per provider per team. That panel also lists which providers your team already has a key for. Omit for no AI filter: every new post found is captured."
          },
          "aiModel": {
            "type": "string",
            "description": "Model id for the chosen provider. OPTIONAL EVEN WHEN aiProvider IS SET \u2014 this field is not required and never has been; a create carrying aiProvider and aiPrompt without it is a 201. Omit it and the provider's DEFAULT MODEL is used: openai `gpt-6-luna`, grok `grok-4.3`, gemini `gemini-3.5-flash-lite`, claude `claude-haiku-4-5-20251001`. Those are a cheap, fast current model from each vendor for what the filter actually does (one keep/reject decision against your criterion), and they MOVE as vendors retire models \u2014 name a model here only when you want one pinned against that. A blank string is treated as unset, the same way the filter itself treats it. A model sent with NO aiProvider is a 400: there is no filter for it to configure, and storing it would leave a setting that never runs."
          },
          "aiPrompt": {
            "type": "string",
            "description": "Your criterion, e.g. \"posts where someone is hiring engineers\". Required when aiProvider is set. EVERY POST A RUN SCANS IS SENT TO YOUR OWN AI PROVIDER, on your key and billed by that provider rather than in Cornersight credits: up to 2,000 posts in each run (the run's internal scan ceiling), 10 to a call \u2014 about 200 calls at most \u2014 or one call per post when the model's batch answer cannot be read. A post already judged under the same prompt is not sent again. `creditCap` does not bound this bill; it bounds Cornersight credits."
          },
          "postBudget": {
            "type": "integer",
            "deprecated": true,
            "description": "RETIRED on 30 September 2026 and IGNORED. It was the per-run post limit; a keyword search now has exactly one limit you set, `creditCap`, and a run collects until it has spent it. Accepted with any value, on create, estimate and resume and under either `contractVersion`, so an older client is never refused for sending it \u2014 it changes nothing, is no longer a scope field the contract requires, and the reply lists it in `ignoredFields`. A `posts_only` search's own limit is `postsPerSync`, which is unaffected."
          },
          "creditCap": {
            "type": "integer",
            "minimum": 1,
            "maximum": 2147483647,
            "default": 100,
            "description": "Maximum enriching credits one run may spend, and the real limit on what a sweep costs \u2014 one credit per new person for this search; repeat engagements are free. PER RUN: the search repeats about every 24 hours, so a creditCap of N is up to N credits every day until the search is untracked. Default 100, and deliberately unbounded above \u2014 no PRODUCT ceiling is imposed, because this bounds a quantity you are paying for rather than one we define. The declared `maximum` of 2147483647 is a STORAGE bound, not a recommendation: it is the largest value the column holds, and a cap anywhere near it would be a spending authorisation renewed every day. WHAT BOUNDS THE SERIES is not this field but your team's monthly enriching-credit balance: a sweep is skipped entirely when the balance is exhausted, so a recurring search stops on its own rather than running forever. IT ALSO BOUNDS PROVIDER PAGES, not just rows written \u2014 the remaining allowance is what decides how many pages of reactions and comments are fetched per post, so a high cap costs provider calls as well as storage. The provider exposes no quota of its own, so this is the only spend control on a sweep."
          },
          "mode": {
            "type": "string",
            "enum": ["engagers", "posts_only"],
            "default": "engagers",
            "description": "engagers captures leads; posts_only saves matching post text without people or leads, charging one credit per new kept post."
          },
          "postsPerSync": {
            "type": "integer",
            "minimum": 1,
            "maximum": 60,
            "description": "Required with posts_only. Maximum new posts to charge in each daily run (1-60); repeats and rejected posts are free."
          },
          "captureEngagers": {
            "type": "boolean",
            "default": true,
            "description": "Capture the people who ENGAGED with each kept post — likes and comments. Default true, which is what every keyword search has always done. false fetches no reactions or comments at all (no provider calls for them) and requires `capturePostAuthors: true`: a search must capture someone (400 `no_capture_target` otherwise). Engagers mode only — sent with `mode: \"posts_only\"` it is a 400. Omitted on a resume or PATCH leaves the stored value alone. Turning it back ON for a search that captured post authors only needs `confirmSpend: true` (409 `spend_confirmation_required` otherwise): the daily figure is `creditCap` either way, but authors only can charge at most one new person per kept post, while engagers can spend the whole cap on one busy post."
          },
          "capturePostAuthors": {
            "type": "boolean",
            "default": false,
            "description": "Capture the person who WROTE each kept post, as a lead with `engagementType: \"Author\"`. Default false. Read from the keyword search result itself, so it costs no extra provider call. Charged exactly like an engager: ONE credit per NEW person for this search; the same person again — on a later post, or also as a liker or commenter of the same post — is a free repeat (the two rows are kept, the person is charged once). A post whose author is a COMPANY PAGE captures no author and costs nothing; the run reports how many as `lastRun.companyAuthorsSkipped`. The credit cap, the team's daily keyword ceiling and a trial source's lead cap bind authors exactly as they bind engagers. With `captureEngagers: false` a run adds at most one new person per kept post, and `estimatedDailyMax` is still `creditCap` \u2014 the one limit you set. Engagers mode only."
          },
          "captureMode": {
            "type": "string",
            "enum": [
              "depth",
              "breadth"
            ],
            "default": "depth",
            "description": "How creditCap is spent across a run's posts. `depth` (the default, and the behaviour of every search created before this field) hands each post the whole remaining cap, so the run goes deep on the posts it reaches first \u2014 one high-engagement post can consume the entire cap and leave later posts uncaptured that run. `breadth` shares the cap across the run's posts by their engagement counts and redistributes the unspent remainder to posts that can absorb it, so the run fans across more posts with fewer engagers from each. Which engagers a capped post contributes is the provider's own order (reactions before comments), not most-recent or most-relevant."
          },
          "maxEngagementsPerPost": {
            "type": "integer",
            "minimum": 1,
            "maximum": 2147483647,
            "description": "Breadth mode only: an explicit per-post ceiling \u2014 capture at most this many engagements from any one post. Omit for no ceiling (the default: breadth fair-shares the whole creditCap across posts). `creditCap` remains the HARD spend limit and this only shapes distribution beneath it: if maxEngagementsPerPost times the posts a run reaches exceeds creditCap the cap still binds and later posts go uncaptured; if it is below creditCap the run spends less than the cap, on purpose \u2014 a deliberately narrower, more even sweep. Ignored in depth mode. The declared `maximum` is the storage bound (int4), not a product ceiling \u2014 breadth has no useful reason to approach it."
          },
          "runOnce": {
            "type": "boolean",
            "default": false,
            "description": "Harvest ONCE, then stop scheduling. Default false, which is the unbounded daily cadence every keyword search has always had. A run that FAILED does not satisfy it \u2014 see `maxRuns` for what counts as a run \u2014 so this means one harvest, not one attempt. Equivalent to `maxRuns: 1` and checked before it; setting both is legal and the tighter binds. A search stopped this way keeps its leads and stays in your source list; `schedule.stoppedAt` on GET /api/v1/sources says when, and `schedule.stoppedReason` says which control did it."
          },
          "endAt": {
            "type": "string",
            "format": "date-time",
            "description": "A UTC instant after which this search stops scheduling. Must be in the FUTURE \u2014 a date already past is a 400, because it would create a search that is parked before it ever runs. Checked before each run as well as after it, so a search whose end passed while it was idle never sweeps again, including from a manual sync. Omit for no end date (the default); send `null` to clear one. Stored normalised to UTC."
          },
          "maxRuns": {
            "type": "integer",
            "minimum": 1,
            "maximum": 3650,
            "description": "Stop after this many COUNTED runs. A run COUNTS when it reached the provider and ended ordinarily (`exhausted`, `credits`, `post_limit`, or a `team_cap`/`lead_cap` that bound it mid-sweep). It does NOT count when the run FAILED (`error`, `ai_error`), when an untrack abandoned it, or when it was skipped before the provider was asked (a spent team ceiling or a full lead cap \u2014 both report `postsScanned: 0`). A sweep that ran and captured nobody DOES count. Raising this above the runs already completed RESTARTS a search that stopped at it \u2014 PATCH /api/v1/keyword/{id} clears the stop and queues the search again in the same call. Omit for no run budget (the default); send `null` to clear one."
          },
          "confirmChanges": {
            "type": "boolean",
            "description": "ACKNOWLEDGE A REWRITE, as `confirmSpend` acknowledges a charge. Only ever relevant on a RESUME: these keywords already name a live search and this body carries a DIFFERENT value for a targeting filter, `sort`, `datePosted`, `contentType` or the AI filter. A resume applies every setting the call carries and leaves the rest alone, and it used to do so in silence \u2014 create with `fromPerson` A, then with `fromPerson` B, and the reply was 200 `resumed: true` with no `previous`, no `changed` and nothing to notice. Without this field such a call is now a `409` `settings_conflict` and NOTHING is changed. `confirmSpend` does NOT acknowledge a rewrite, and `confirmChanges` does not authorise a cap rise \u2014 two changes, two fields \u2014 so a body that does both carries both. It was accepted for both once, and that made the refusal unreachable from the CLI (`track-keyword` requires `--confirm-spend`, so every create it sent arrived pre-acknowledged and rewrote live searches in silence) and from any caller that sent it early. The pair still costs ONE round trip: a `spend_confirmation_required` refusal on a body that would also rewrite something carries `changed` and names it. Ignored when nothing would change. Accepted and inert on POST /api/v1/keyword/estimate, which changes nothing to acknowledge."
          },
          "confirmSpend": {
            "type": "boolean",
            "description": "THE SPEND CONFIRMATION, and the one field that is about consent rather than configuration. `true` means the person who asked for this search has been shown what one day of it can cost and has agreed to it. THE THRESHOLD IS EVERY CREATE \u2014 there is no credit figure below which it is skipped \u2014 because a keyword search is a RECURRING DAILY CHARGE whose only upper bound is your team's monthly balance, and the thing being authorised is the commitment, not an amount. It is required on a create, on a RESUME (keywords matching an existing search re-cap THAT search rather than making a second one) and on any update that RAISES what one day can cost (`creditCap`, or `postsPerSync` on a `posts_only` search). Without it the request is refused `409` with code `spend_confirmation_required`, whose body carries `creditCap`, `captureMode`, `datePosted`, `estimatedDailyMax`, `remainingBalance` and `daysToExhaustAtCap` \u2014 the numbers the dashboard puts above its Confirm button. Show them to the person, then re-send the identical request with this field added. DURING THE WARNING PHASE (see `contractVersion`) an omitted `confirmSpend` still returns 201/200, with a `deprecations` entry naming it and the cut-over date after which it will not. \u26a0\ufe0f THE CUT-OVER IS SCHEDULED FOR 2026-11-01, AND THIS IS THE NOTICE OF IT. UNTIL 2026-11-01 an old-style create \u2014 one that omits the three scope fields and `confirmSpend` \u2014 still returns 201/200 and carries a `deprecations` entry naming exactly what to add. FROM 2026-11-01 the same request is refused: a missing scope field is `400` `scope_required` naming the field, and a scope that is stated but not confirmed is `409` `spend_confirmation_required` \u2014 the same refusal a request pinned to `contractVersion: \"2026-11-01\"` already gets today, which is how you test the new behaviour before the date. THE EXACT CHANGE IS FOUR KEYS: add \"creditCap\": 100, \"captureMode\": \"depth\", \"datePosted\": \"PAST_WEEK\" and \"confirmSpend\": true to the request body. Those three values are what the dashboard's \"Search scope\" step pre-fills, offered so you can copy them DELIBERATELY \u2014 they are NOT server-side defaults and nothing is chosen for you, so send the caps you actually want, and send `confirmSpend` only once the person has heard the daily figure. THE VERSION STRING AND THE CUT-OVER DATE ARE THE SAME DAY, which they did not have to be: the version is what you PIN, the date is what happens to you if you do not."
          },
          "contractVersion": {
            "type": "string",
            "enum": [
              "2026-09-09",
              "2026-11-01"
            ],
            "description": "WHICH REVISION OF THIS ENDPOINT'S CONTRACT TO BE HELD TO, so an integration can move on its own schedule instead of on ours. `2026-11-01` is the spend contract: `creditCap`, `captureMode` and `datePosted` all required, and `confirmSpend: true` required \u2014 send it and those rules apply to this request immediately, whichever phase the server is in. REVISED ON 30 SEPTEMBER 2026, BEFORE ITS CUT-OVER: it named a fourth required field, `postBudget`, which left the contract when the per-run post limit was retired. The version string did not change because the revision only removes a requirement \u2014 nothing that was accepted before is refused now \u2014 and a request that still sends `postBudget`, pinned or not, is accepted with the value ignored and listed in the reply's `ignoredFields`. `2026-09-09` DECLARES the older contract (no scope fields, no confirmation), which is what an omitted `contractVersion` also gets, and is accepted only until the cut-over; after it, a request pinned to the old version is refused like any other and the message says the pin is why. ANY OTHER VALUE IS A `400` with code `invalid_contract_version`, in both phases \u2014 a misspelled opt-in that was silently ignored would mean believing you had moved when you had not. THE ROLL-OUT IN ONE SENTENCE: today an old-style create still succeeds and carries `deprecations` telling you exactly what to add and by when; from the cut-over the same request is a `400` (`scope_required`) or a `409` (`spend_confirmation_required`). The cut-over instant is published on every `deprecations` entry as `cutoverAt`, and is `null` there while it is unscheduled. \u26a0\ufe0f THE CUT-OVER IS SCHEDULED FOR 2026-11-01, AND THIS IS THE NOTICE OF IT. UNTIL 2026-11-01 an old-style create \u2014 one that omits the three scope fields and `confirmSpend` \u2014 still returns 201/200 and carries a `deprecations` entry naming exactly what to add. FROM 2026-11-01 the same request is refused: a missing scope field is `400` `scope_required` naming the field, and a scope that is stated but not confirmed is `409` `spend_confirmation_required` \u2014 the same refusal a request pinned to `contractVersion: \"2026-11-01\"` already gets today, which is how you test the new behaviour before the date. THE EXACT CHANGE IS FOUR KEYS: add \"creditCap\": 100, \"captureMode\": \"depth\", \"datePosted\": \"PAST_WEEK\" and \"confirmSpend\": true to the request body. Those three values are what the dashboard's \"Search scope\" step pre-fills, offered so you can copy them DELIBERATELY \u2014 they are NOT server-side defaults and nothing is chosen for you, so send the caps you actually want, and send `confirmSpend` only once the person has heard the daily figure. THE VERSION STRING AND THE CUT-OVER DATE ARE THE SAME DAY, which they did not have to be: the version is what you PIN, the date is what happens to you if you do not. The date above is the published schedule; `cutoverAt` carries it as an instant once this deployment has been configured with it, and is `null` until then \u2014 so read the date from this documentation and treat `null` as \"not configured here yet\" rather than as \"not happening\"."
          }
        }
      },
      "TrackKeywordResponse": {
        "type": "object",
        "properties": {
          "filtering": {
            "type": "object",
            "description": "WHETHER THIS SEARCH WILL FILTER THE POSTS IT FINDS, recorded beside what it can cost because the two are the same decision seen from either end. `applied` is true when the search will run with at least one targeting filter (`authorKeyword`, `authorIndustry`, `authorCompany`, `fromPerson`, `fromCompany`, `mentionsPerson`, `mentionsCompany`) or with a complete AI filter (`aiProvider` AND a non-blank `aiPrompt`). \u26a0\ufe0f IT DESCRIBES THE SEARCH AS IT NOW STANDS, NOT YOUR REQUEST: on a RESUME a filter you did not mention is left in place, so a bare `{\"keywords\"}` against an AI-filtered search reports `true` \u2014 the same rule `name` and `aiProvider` above follow. A MISSING FILTER IS NEVER A REASON TO REFUSE: `applied: false` is a record of your choice, not a fault, and a create that omits both kinds is a 201 exactly as it always was. There is no `suggestion` key here \u2014 the offer is made once, on the `409`, at the moment you are being asked about the cost.",
            "properties": {
              "applied": {
                "type": "boolean",
                "description": "True when at least one targeting filter, or a complete AI filter (provider AND a non-blank prompt), will be in force for the next run. Always present, so a caller can branch on it without probing. A provider with no prompt is not a filter \u2014 it is a `400` on the way in."
              },
              "aiKeyStoredFor": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "Which of `openai`, `grok`, `gemini` and `claude` this team has an AI key stored for \u2014 sorted, de-duplicated, and possibly empty. Answered from the stored credential's provider column; the key itself is never decrypted, never returned, and cannot be sent to this endpoint at all (it is saved once per provider in the dashboard). It is here so you can tell whether an AI filter is possible RIGHT NOW without a second call: with an empty list, only the targeting filters are available."
              }
            }
          },
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "The tracked source's id \u2014 the same id GET /api/v1/sources returns, and what DELETE /api/v1/keyword/{id} takes. On a duplicate this is the EXISTING search's id, with its original createdAt."
          },
          "name": {
            "type": [
              "string",
              "null"
            ],
            "description": "What the search is called, or null if it is titled by its terms. The search's CURRENT name \u2014 on a duplicate that omitted `name`, the stored one, not the null your request implied."
          },
          "keywords": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "The terms as stored, trimmed \u2014 and, when this search was created from an `expression`, the terms that expression COMPILED to: its required terms, de-duplicated, in first-appearance order. Always the terms actually searched for, whichever way the search was made."
          },
          "matchLogic": {
            "type": "string",
            "description": "Present only when `keywords` has more than one term and no `expression` was sent \u2014 the plain-list OR shape. Says plainly that this search matches ANY of these terms, not all of them together, and names the `expression` syntax to use instead if that is not what was wanted. Absent for a single term and for any `expression`: typing AND, OR or NOT is itself the acknowledgement, so this never second-guesses a caller who already named the logic."
          },
          "expression": {
            "type": "string",
            "nullable": true,
            "description": "The boolean expression this search was created from, in CANONICAL form \u2014 operators upper case, the implicit OR written out, and a multi-literal group bracketed when there is more than one group, e.g. `(hiring AND NOT recruiter) OR fundraising`. It is NOT an echo of what was sent: the canonical form is where the precedence is visible. It is also the one bracketed form the create accepts: sent back unchanged as `expression`, it compiles to the same search. READ IT BACK before you rely on the search \u2014 it is how you confirm the operators were parsed the way you meant. `null` for every search created from a plain `keywords` list. As with `name` and `aiProvider`, on a RESUME this is the search's CURRENT value rather than an echo of the request."
          },
          "aiProvider": {
            "type": [
              "string",
              "null"
            ],
            "description": "Which stored credential filters this search's posts, or null for no AI filter. As with `name`, the search's CURRENT value rather than an echo of the request."
          },
          "captureEngagers": {
            "type": "boolean",
            "nullable": true,
            "description": "Whether the search captures people who liked or commented (migration 176). Null on a `posts_only` search, which captures no people."
          },
          "capturePostAuthors": {
            "type": "boolean",
            "nullable": true,
            "description": "Whether the search captures each kept post's author as an `Author` lead (migration 176). Null on a `posts_only` search, which captures no people."
          },
          "resumed": {
            "type": "boolean",
            "description": "false on a genuine create (201). true when this team already had a search with these keywords and it was returned instead (200) \u2014 active or previously deleted. Always present, so a caller can branch on it without probing."
          },
          "seenPosts": {
            "type": "integer",
            "description": "How many posts the returned search has already swept and will therefore SKIP: a resumed search inherits its seen-set and does not start clean. 0 on a genuine create."
          },
          "syncId": {
            "type": [
              "string",
              "null"
            ],
            "description": "Id of the first sweep queued, or null if the team's plan does not allow capture."
          },
          "estimatedDailyMax": {
            "type": "integer",
            "description": "THE MOST ONE DAY OF THIS SEARCH CAN COST, in enriching credits \u2014 the same number the dashboard shows as \"This search can cost up to N credits a day\" above its Confirm button, produced by the one shared function every surface quoting a spend estimate reads. The formula \u2014 `creditCap`, or `min(postsPerSync, creditCap)` for a `posts_only` search \u2014 and the fields deliberately not in it are written out in this operation's description. RELAY IT TO WHOEVER ASKED FOR THE SEARCH, before or as you create it: the first sweep starts within SECONDS of this response and the charge repeats every day until the search is untracked, so there is no window in which to check afterwards and no confirmation step on this endpoint. OMITTED \u2014 not `0`, not `null` \u2014 when `creditCap` is missing or is not a positive whole number, because \"up to 0 credits a day\" is a promise the product cannot keep. Test for the KEY's presence (`'estimatedDailyMax' in body`), never for a value."
          },
          "daysToExhaustAtCap": {
            "type": "integer",
            "description": "WHOLE days your team's remaining enriching-credit balance funds at that daily maximum. `0` IS A REAL ANSWER AND THE ALARMING ONE: the balance cannot fund one whole day at this cap, so the sweep this request has already queued is the one that gets cut short. OMITTED \u2014 never zero, never infinite \u2014 when there is no rate to divide by (`estimatedDailyMax` is absent too) or the balance could not be read at all, as on a team with no enriching plan. Both of those mean the question has no answer, which is a different thing from the answer being none, so test for the KEY's presence."
          },
          "ignoredFields": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "postBudget"
              ]
            },
            "description": "The fields this request sent that NOTHING READS ANY MORE, so a setting you still send is never dropped in silence. Today that can only be `postBudget` \u2014 the per-run post limit, retired on 30 September 2026: accepted with any value, applied to nothing, and named here. ABSENT when the request sent none."
          },
          "deprecations": {
            "type": "array",
            "description": "WHAT THIS CREATE WOULD BE REFUSED FOR ONCE THE SPEND CONTRACT IS ENFORCED, and the exact object to add so that it is not. PRESENT ONLY DURING THE WARNING PHASE, and only when this request did not state the whole scope \u2014 a caller who already sends the four scope fields and `confirmSpend: true` gets a body with no `deprecations` key at all, and so does every caller once the phase ends, because then the same request is a `400` or a `409` rather than a warning. Each entry carries `code` (`spend_confirmation_required`), `contractVersion`, `cutoverAt` (an ISO instant, or `null` while the cut-over is unscheduled; THE CUT-OVER IS SCHEDULED FOR 2026-11-01), `message`, and `add`. \u26a0\ufe0f `add` IS THE VALUE THIS REQUEST ACTUALLY USED, not a recommendation: on a create those are the dashboard's pre-fills, and on a RESUME they are the existing search's own settings, because an omitted field leaves that search's caps alone. Merging `add` into your request body therefore changes nothing about what runs \u2014 it only makes the decision explicit, which is the whole of what the new contract asks for.",
            "items": {
              "type": "object",
              "properties": {
                "code": {
                  "type": "string",
                  "description": "`spend_confirmation_required` \u2014 the same code the 409 carries, so one branch handles both."
                },
                "contractVersion": {
                  "type": "string",
                  "description": "The contract this warning is about: `2026-11-01`."
                },
                "cutoverAt": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "The instant the new contract becomes the default, ISO 8601. `null` while it is unscheduled \u2014 never an invented date. \u26a0\ufe0f THE CUT-OVER IS SCHEDULED FOR 2026-11-01, AND THIS IS THE NOTICE OF IT. UNTIL 2026-11-01 an old-style create \u2014 one that omits the three scope fields and `confirmSpend` \u2014 still returns 201/200 and carries a `deprecations` entry naming exactly what to add. FROM 2026-11-01 the same request is refused: a missing scope field is `400` `scope_required` naming the field, and a scope that is stated but not confirmed is `409` `spend_confirmation_required` \u2014 the same refusal a request pinned to `contractVersion: \"2026-11-01\"` already gets today, which is how you test the new behaviour before the date. THE EXACT CHANGE IS FOUR KEYS: add \"creditCap\": 100, \"captureMode\": \"depth\", \"datePosted\": \"PAST_WEEK\" and \"confirmSpend\": true to the request body. Those three values are what the dashboard's \"Search scope\" step pre-fills, offered so you can copy them DELIBERATELY \u2014 they are NOT server-side defaults and nothing is chosen for you, so send the caps you actually want, and send `confirmSpend` only once the person has heard the daily figure. THE VERSION STRING AND THE CUT-OVER DATE ARE THE SAME DAY, which they did not have to be: the version is what you PIN, the date is what happens to you if you do not. The date above is the published schedule; `cutoverAt` carries it as an instant once this deployment has been configured with it, and is `null` until then \u2014 so read the date from this documentation and treat `null` as \"not configured here yet\" rather than as \"not happening\"."
                },
                "add": {
                  "type": "object",
                  "description": "The exact JSON to merge into the request body. Values are the ones this request already resolved to, so merging them is a no-op in behaviour.",
                  "additionalProperties": true
                },
                "message": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "KeywordEstimateResponse": {
        "type": "object",
        "description": "What the search this body describes would be bound by, and what one day of it can cost. Nothing was created.",
        "properties": {
          "matchLogic": {
            "type": "string",
            "description": "Present only when `keywords` has more than one term and no `expression` was sent — the plain-list OR shape. Says plainly that this search matches ANY of these terms, not all of them together, and names the `expression` syntax to use instead if that is not what was wanted. Absent for a single term and for any `expression`: typing AND, OR or NOT is itself the acknowledgement, so this never second-guesses a caller who already named the logic. Identical to what POST /api/v1/keyword/track would report for the same body — estimate_keyword_search is the FIRST call track_keyword's own description tells an agent to make, so this is where it matters most, before anything is spent."
          },
          "creditCap": {
            "type": "integer",
            "description": "Maximum enriching credits one run could spend, resolved the same way. PER RUN, and the sweep repeats about every 24 hours."
          },
          "captureMode": {
            "type": "string",
            "enum": [
              "depth",
              "breadth"
            ],
            "description": "How the cap would be spent across a run's posts, resolved the same way."
          },
          "datePosted": {
            "type": "string",
            "enum": [
              "PAST_24_HOURS",
              "PAST_WEEK",
              "PAST_MONTH"
            ],
            "description": "How far back each sweep would look, resolved the same way."
          },
          "captureEngagers": {
            "type": "boolean",
            "nullable": true,
            "description": "Whether the search captures people who liked or commented (migration 176). Null on a `posts_only` search, which captures no people."
          },
          "capturePostAuthors": {
            "type": "boolean",
            "nullable": true,
            "description": "Whether the search captures each kept post's author as an `Author` lead (migration 176). Null on a `posts_only` search, which captures no people."
          },
          "resumed": {
            "type": "boolean",
            "description": "true when these terms already name a search this team has, so POST /api/v1/keyword/track would RESUME it rather than create a second one \u2014 applying the settings you named, leaving the rest alone, and re-tracking it with a charging sweep if it had been deleted. Always present."
          },
          "previous": {
            "type": "object",
            "description": "The live search's CURRENT caps, present ONLY when `resumed` is true \u2014 there is nothing for a genuine create to be measured against. The same key, the same shape and the same meaning the 409 gives it: what the search is bound by BEFORE this body would be applied.",
            "properties": {
              "creditCap": {
                "type": "integer"
              },
              "captureMode": {
                "type": "string"
              },
              "datePosted": {
                "type": "string"
              }
            }
          },
          "remainingBalance": {
            "type": "integer",
            "description": "Your team's remaining enriching credits. OMITTED when the balance could not be read."
          },
          "estimatedDailyMax": {
            "type": "integer",
            "description": "The most ONE DAY of this search could cost in enriching credits \u2014 the same figure the create returns and the 409 quotes, from the same shared function. OMITTED, never null and never 0, when there is no usable cap. For a `posts_only` body it is `min(postsPerSync, creditCap)` \u2014 the figure the create's 201/200, its 409 and PATCH quote for the same settings. The formula is written out on POST /api/v1/keyword/track."
          },
          "daysToExhaustAtCap": {
            "type": "integer",
            "description": "WHOLE days your remaining balance funds at that rate. OMITTED, never null, when there is no rate or no readable balance \u2014 while a value of 0 is a real answer and the alarming one: the balance cannot fund one whole day, so the first sweep would be cut short."
          },
          "ignoredFields": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "postBudget"
              ]
            },
            "description": "The fields this request sent that NOTHING READS ANY MORE, so a setting you still send is never dropped in silence. Today that can only be `postBudget` \u2014 the per-run post limit, retired on 30 September 2026: accepted with any value, applied to nothing, and named here. ABSENT when the request sent none."
          },
          "filtering": {
            "type": "object",
            "description": "Whether this search WOULD filter the posts it finds, resolved as the search will stand \u2014 sent wins, omitted inherits from the live search \u2014 so a bare estimate against an AI-filtered search reports `applied: true`. Unlike the create's success body this one DOES carry `suggestion` when `applied` is false, because this is the call you make while you are looking at the cost, which is the moment the 409 makes the same offer. It is never a gate: an unfiltered search is priced and created exactly as it always was.",
            "properties": {
              "applied": {
                "type": "boolean",
                "description": "True when at least one targeting filter, or a complete AI filter (provider AND a non-blank prompt), would be in force. Always present."
              },
              "suggestion": {
                "type": "string",
                "description": "One sentence, written for the person, offering the filters this team could actually use today. Present ONLY when `applied` is false."
              },
              "aiKeyStoredFor": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "Which of `openai`, `grok`, `gemini` and `claude` this team has a key stored for \u2014 sorted, de-duplicated, possibly empty. Answered from the credential's provider column; no key is ever decrypted or returned."
              }
            }
          }
        }
      },
      "LeadPushRequest": {
        "type": "object",
        "description": "Which already-captured leads to push, and how the call behaves if you retry it. Every field is optional: an empty body pushes every eligible lead captured before now.",
        "properties": {
          "scope": {
            "type": "string",
            "enum": [
              "all",
              "icp"
            ],
            "default": "all",
            "description": "Which leads to select. `all` = every eligible lead on the source. `icp` = only leads with `isIcp: true`. This is SELECTION, not delivery filtering: the webhook's own `icpOnly` flag still applies afterwards, so an `all` push to an `icpOnly` webhook delivers only the ICP subset and reports the rest as `held`."
          },
          "since": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "Inclusive lower bound on the lead's `detectedAt`. Omit for \"from the beginning\". Use the `nextSince` from a truncated push to continue it."
          },
          "until": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "EXCLUSIVE upper bound on the lead's `detectedAt`. Omit and it is stamped with the instant the push is accepted \u2014 which is what makes the cohort historical, closed and reproducible. Leads detected at or after that instant are FUTURE and are delivered by auto-send instead."
          },
          "idempotencyKey": {
            "type": "string",
            "maxLength": 200,
            "description": "Retry-safety opt-in, 1-200 characters of letters, digits, dot, underscore, colon or hyphen. A second push with the same key returns the first push's result with `replayed: true` and queues NOTHING; the same key with different parameters returns 409. Omit it and every call is a distinct push."
          },
          "dryRun": {
            "type": "boolean",
            "default": false,
            "description": "Count the cohort and change nothing: no leads are queued, no push is recorded, and the idempotencyKey is not consumed. Use it to see how large an `all` vs `icp` selection is before sending."
          }
        }
      },
      "LeadPushResult": {
        "type": "object",
        "required": [
          "pushId",
          "scope",
          "counts",
          "replayed",
          "dryRun"
        ],
        "properties": {
          "pushId": {
            "type": "string",
            "format": "uuid",
            "nullable": true,
            "description": "Id of the recorded push, for `GET /api/v1/push/{pushId}`. Null on a dry run, which records nothing."
          },
          "username": {
            "type": "string",
            "description": "The source's identifier as stored \u2014 for a keyword search this is its keyword text, not its id."
          },
          "profileType": {
            "type": "string",
            "enum": [
              "person",
              "company",
              "keyword"
            ]
          },
          "scope": {
            "type": "string",
            "enum": [
              "all",
              "icp"
            ]
          },
          "selection": {
            "type": "object",
            "description": "The window this push resolved to. `until` is always concrete, even when it was omitted from the request.",
            "properties": {
              "since": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              },
              "until": {
                "type": "string",
                "format": "date-time"
              }
            }
          },
          "idempotencyKey": {
            "type": "string",
            "nullable": true,
            "description": "The key this push is recorded under \u2014 generated if you did not supply one. Null on a dry run."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "counts": {
            "type": "object",
            "properties": {
              "selected": {
                "type": "integer",
                "description": "Eligible leads matching the scope and window."
              },
              "queued": {
                "type": "integer",
                "description": "Of those, the ones this push moved to `pending`. Lower than `selected` when some were already queued or in flight \u2014 those are left alone rather than re-queued, which is what stops a push racing a delivery already under way."
              }
            }
          },
          "statusUrl": {
            "type": "string",
            "nullable": true,
            "description": "Where to poll progress. Null on a dry run."
          },
          "replayed": {
            "type": "boolean",
            "description": "True when this call matched an existing `idempotencyKey` and therefore queued nothing \u2014 the counts are the ORIGINAL push's."
          },
          "dryRun": {
            "type": "boolean"
          },
          "truncated": {
            "type": "boolean",
            "description": "True when the cohort was larger than the 2000-lead per-push limit and only the oldest 2000 were taken."
          },
          "nextSince": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "When `truncated`, the `since` to repeat the push with (using a NEW idempotencyKey) to continue. Inclusive, so a lead sharing the boundary timestamp is re-selected rather than skipped \u2014 re-selecting is harmless, skipping would silently lose a lead."
          },
          "notSelected": {
            "type": "object",
            "nullable": true,
            "description": "Leads in the same scope and window that this push did NOT select because they are not enriched \u2014 so a push that selects few or none says why. Null on a replay (it was not recorded) or if the count could not be taken.",
            "properties": {
              "awaitingEnrichment": {
                "type": "integer",
                "description": "Enrichment is still owed. Not queued by this push: once enriched they go out through auto-send (when it is on) or a later push."
              },
              "enrichmentFailed": {
                "type": "integer",
                "description": "Enrichment ended without data (failed, empty or skipped). These are never delivered: the payload is built from enrichment fields."
              }
            }
          }
        }
      },
      "LeadPushProgress": {
        "type": "object",
        "required": [
          "pushId",
          "counts",
          "done"
        ],
        "properties": {
          "pushId": {
            "type": "string",
            "format": "uuid"
          },
          "username": {
            "type": "string",
            "nullable": true
          },
          "profileType": {
            "type": "string",
            "nullable": true
          },
          "scope": {
            "type": "string",
            "enum": [
              "all",
              "icp"
            ]
          },
          "selection": {
            "type": "object",
            "properties": {
              "since": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              },
              "until": {
                "type": "string",
                "format": "date-time"
              }
            }
          },
          "idempotencyKey": {
            "type": "string"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "counts": {
            "type": "object",
            "description": "`selected` and `queued` are frozen as of the push; the five status counts are LIVE, over exactly this push's cohort.",
            "properties": {
              "selected": {
                "type": "integer"
              },
              "queued": {
                "type": "integer"
              },
              "pending": {
                "type": "integer",
                "description": "Queued and NOT YET ATTEMPTED \u2014 not yet claimed by the delivery poller."
              },
              "sending": {
                "type": "integer",
                "description": "Claimed by a delivery in flight. The same number as `delivering`, under the name this route shipped with."
              },
              "delivering": {
                "type": "integer",
                "description": "Claimed by a delivery in flight: attempted, outcome not yet recorded."
              },
              "delivered": {
                "type": "integer",
                "description": "`sent` \u2014 every configured destination accepted it."
              },
              "failed": {
                "type": "integer",
                "description": "A destination rejected it or could not be reached after its automatic attempts (3 within the same sweep, for a transport error, 408, 429 or 5xx; a 4xx is not retried). Re-push to try again."
              },
              "held": {
                "type": "integer",
                "description": "`no_webhook` \u2014 the delivery path had nothing to send this lead to. Normally an `icpOnly` webhook rejecting a non-ICP lead, which is what an `all`-scope push against an ICP-only webhook produces."
              }
            }
          },
          "status": {
            "type": "string",
            "enum": [
              "queued",
              "delivering",
              "delivered",
              "failed",
              "held"
            ],
            "description": "Where the push stands in one word. `queued`: none of its leads has been attempted yet. `delivering`: some attempted, some not yet settled. Once settled: `failed` if any lead failed, else `delivered` if any was sent, else `held` (the webhook's icpOnly filter took every lead)."
          },
          "stalled": {
            "type": "boolean",
            "description": "True when leads of this push are still `pending` \u2014 never attempted \u2014 `stalledAfterSeconds` or more after the push was accepted. A healthy push is attempted within about a minute, so this is a delay on Cornersight's side, not your endpoint; ops is alerted on the same condition."
          },
          "stalledAfterSeconds": {
            "type": "integer",
            "description": "The threshold `stalled` is judged against: 300."
          },
          "done": {
            "type": "boolean",
            "description": "True once nothing is `pending` or `sending`."
          },
          "settled": {
            "type": "integer",
            "description": "delivered + failed + held."
          },
          "total": {
            "type": "integer",
            "description": "All five live counts summed. Can differ from `selected` if leads were deleted after the push."
          },
          "percent": {
            "type": "integer",
            "description": "settled/total as a whole percentage; 100 when the cohort is empty."
          }
        }
      },
      "DiscoverRunRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "keywords",
          "minEngagement",
          "maxInfluencers"
        ],
        "properties": {
          "keywords": {
            "type": "array",
            "minItems": 1,
            "maxItems": 10,
            "items": {
              "type": "string",
              "minLength": 1
            },
            "description": "1-10 keywords. A post matching ANY of them counts (no AND/NOT); each is a separate LinkedIn search. No quotation marks or brackets; the same keyword twice (ignoring case) is a 400."
          },
          "minEngagement": {
            "type": "integer",
            "minimum": 0,
            "maximum": 1000000,
            "description": "The minimum AVERAGE likes + comments per matching post a person needs to be added."
          },
          "maxInfluencers": {
            "type": "integer",
            "minimum": 1,
            "maximum": 500,
            "description": "The most people this run adds, highest averages first. Also the most credits it can spend (1 per person)."
          },
          "countries": {
            "type": "array",
            "maxItems": 20,
            "items": {
              "type": "string",
              "maxLength": 80
            },
            "description": "Optional. Only people whose LinkedIn profile is in one of these countries are added. Short forms work (UK, USA, UAE); an entry with no letters is a 400. Omit for any country."
          },
          "confirmSpend": {
            "type": "boolean",
            "description": "Authorises up to maxInfluencers credits, once. Without it the request is a 409 spend_confirmation_required and nothing is created."
          }
        }
      },
      "DiscoverRun": {
        "type": "object",
        "required": [
          "id",
          "keywords",
          "expression",
          "minEngagement",
          "maxInfluencers",
          "countries",
          "datePosted",
          "state",
          "candidates",
          "qualified",
          "found",
          "countryChecked",
          "countryMatched",
          "createdAt",
          "finishedAt",
          "stoppedBy",
          "reason"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "The run's id."
          },
          "keywords": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "The topic: the keywords searched."
          },
          "expression": {
            "type": "string",
            "description": "The keywords as the one OR search they compile to."
          },
          "minEngagement": {
            "type": "integer",
            "description": "The minimum average likes + comments per matching post."
          },
          "maxInfluencers": {
            "type": "integer",
            "description": "The most people the run may add."
          },
          "countries": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "The countries the run is limited to, as saved. Empty = any country."
          },
          "datePosted": {
            "type": "string",
            "enum": [
              "PAST_MONTH"
            ],
            "description": "The window: always the past month."
          },
          "state": {
            "type": "string",
            "enum": [
              "running",
              "done",
              "failed"
            ],
            "description": "running until the run finishes; failed if it stopped on an error; done otherwise (with found 0, the dashboard shows \"No results\")."
          },
          "candidates": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Distinct people (not company pages) among the posts the run read. Null until the run finishes."
          },
          "qualified": {
            "type": [
              "integer",
              "null"
            ],
            "description": "How many of them averaged at least minEngagement (and, with countries, were in one). Null until the run finishes. More than `found` means the run stopped at maxInfluencers."
          },
          "found": {
            "type": "integer",
            "description": "How many influencers the run added, and charged 1 credit each for."
          },
          "countryChecked": {
            "type": [
              "integer",
              "null"
            ],
            "description": "With countries: how many qualifying people were looked up. Null until the run finishes."
          },
          "countryMatched": {
            "type": [
              "integer",
              "null"
            ],
            "description": "With countries: how many of them were in one of the countries. Null until the run finishes."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "finishedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the run finished. Null while running."
          },
          "stoppedBy": {
            "type": [
              "string",
              "null"
            ],
            "description": "Why the run ended, as keyword runs report it (e.g. exhausted, credit_cap, credits, error)."
          },
          "reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "A sentence about how the run ended, when there is one."
          }
        }
      },
      "DiscoverRunList": {
        "type": "object",
        "required": [
          "runs",
          "total"
        ],
        "properties": {
          "runs": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/DiscoverRun"
            }
          },
          "total": {
            "type": "integer",
            "description": "How many runs are in `runs`."
          }
        }
      },
      "DiscoverInfluencer": {
        "type": "object",
        "required": [
          "name",
          "linkedinUrl",
          "avatarUrl",
          "jobTitle",
          "company",
          "country",
          "highestEngagement",
          "avgEngagement",
          "postCount",
          "totalEngagement",
          "topPostUrl",
          "topPostText"
        ],
        "properties": {
          "name": {
            "type": "string"
          },
          "linkedinUrl": {
            "type": [
              "string",
              "null"
            ],
            "description": "Their LinkedIn profile URL."
          },
          "avatarUrl": {
            "type": [
              "string",
              "null"
            ]
          },
          "jobTitle": {
            "type": [
              "string",
              "null"
            ],
            "description": "From enrichment, shortly after capture. Null until then."
          },
          "company": {
            "type": [
              "string",
              "null"
            ],
            "description": "From enrichment, shortly after capture. Null until then."
          },
          "country": {
            "type": [
              "string",
              "null"
            ],
            "description": "From enrichment, shortly after capture. Null until then."
          },
          "highestEngagement": {
            "type": "number",
            "description": "Likes + comments on their best matching post (the one they were captured from). The average until that post is stored."
          },
          "avgEngagement": {
            "type": "number",
            "description": "Average likes + comments across their matching posts, to 2 decimals. This is what minEngagement is compared with."
          },
          "postCount": {
            "type": "integer",
            "description": "How many of their posts matched."
          },
          "totalEngagement": {
            "type": "integer",
            "description": "Likes + comments summed over their matching posts."
          },
          "topPostUrl": {
            "type": [
              "string",
              "null"
            ],
            "description": "Their best matching post."
          },
          "topPostText": {
            "type": [
              "string",
              "null"
            ],
            "description": "That post's text, when stored."
          }
        }
      },
      "DiscoverRunDetail": {
        "type": "object",
        "required": [
          "run",
          "influencers"
        ],
        "properties": {
          "run": {
            "$ref": "#/components/schemas/DiscoverRun"
          },
          "influencers": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/DiscoverInfluencer"
            },
            "description": "Highest average first."
          }
        }
      }
    },
    "responses": {
      "MissingUsername": {
        "description": "The `username` field is required.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "examples": {
              "missingUsername": {
                "value": {
                  "error": "username is required"
                }
              }
            }
          }
        }
      },
      "MissingPostUrn": {
        "description": "The `postUrn` field is required.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "examples": {
              "missingPostUrn": {
                "value": {
                  "error": "postUrn is required"
                }
              }
            }
          }
        }
      },
      "Unauthorized": {
        "description": "`X-API-Key` is missing or invalid.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "examples": {
              "missingApiKey": {
                "summary": "Missing API key",
                "value": {
                  "error": "Missing X-API-Key header"
                }
              },
              "invalidApiKey": {
                "summary": "Invalid API key",
                "value": {
                  "error": "Invalid API key"
                }
              }
            }
          }
        }
      },
      "SubscriptionInactive": {
        "description": "The authenticated team does not have an active subscription or an unexpired trial.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "examples": {
              "subscriptionInactive": {
                "value": {
                  "error": "Team subscription not active"
                }
              }
            }
          }
        }
      },
      "TrackedProfileDeleteForbidden": {
        "description": "The delete is forbidden \u2014 either the team's subscription is inactive / its trial has expired, or the profile is a trial profile (trial profiles cannot be deleted; subscribe to a paid plan to manage profiles).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "examples": {
              "subscriptionInactive": {
                "summary": "Subscription inactive",
                "value": {
                  "error": "Team subscription not active"
                }
              },
              "trialProfile": {
                "summary": "Trial profile cannot be deleted",
                "value": {
                  "error": "Trial profiles cannot be deleted. Subscribe to a plan to manage profiles."
                }
              }
            }
          }
        }
      },
      "LeadNotFound": {
        "description": "No compatible lead exists for the requested enrichment.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "examples": {
              "leadNotFound": {
                "value": {
                  "error": "Lead not found for demo-profile. Set saveTrackedProfile=true to create or sync a tracked profile instead."
                }
              }
            }
          }
        }
      },
      "CompanyLeadNotFound": {
        "description": "No compatible company lead exists for the requested enrichment.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "examples": {
              "leadNotFound": {
                "value": {
                  "error": "Lead not found for company Demo Company. Set saveTrackedProfile=true to create or sync a tracked profile instead."
                }
              }
            }
          }
        }
      },
      "ProfileNotTracked": {
        "description": "The personal LinkedIn profile is not tracked by the authenticated team, or it has been untracked.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "examples": {
              "profileNotTracked": {
                "value": {
                  "error": "Profile not tracked"
                }
              }
            }
          }
        }
      },
      "CompanyProfileNotTracked": {
        "description": "The LinkedIn company page is not tracked by the authenticated team, or it has been untracked.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "examples": {
              "companyProfileNotTracked": {
                "value": {
                  "error": "Company profile not tracked"
                }
              }
            }
          }
        }
      },
      "PostNotFound": {
        "description": "The post cannot be pulled. `Post not found`: this team holds no such post - not as a tracked post, a keyword search's harvested post or a posts-only watch's post - and a post only ANOTHER team holds answers exactly the same. An error starting `Post not tracked`: the team does hold it, but only as an older post of a tracked profile or company page (outside its 15 most recent) or under an untracked posts-only watch; the message names the fix, POST /api/v1/post/track.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "examples": {
              "postNotFound": {
                "value": {
                  "error": "Post not found"
                }
              },
              "postNotTracked": {
                "value": {
                  "error": "Post not tracked: this team holds that post only as an older post of a tracked profile or company page (outside the 15 most recent its capture follows) or under a source that has been untracked, so its reactions and comments cannot be pulled. Track the post itself with POST /api/v1/post/track, then retry once its first sync has finished."
                }
              }
            }
          }
        }
      },
      "JobNotFound": {
        "description": "The `jobId` does not exist or belongs to another team.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "examples": {
              "jobNotFound": {
                "value": {
                  "error": "Job not found"
                }
              }
            }
          }
        }
      },
      "RateLimited": {
        "description": "Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers.",
        "headers": {
          "Retry-After": {
            "schema": {
              "type": "integer"
            },
            "description": "Seconds to wait before retrying."
          },
          "X-RateLimit-Limit": {
            "schema": {
              "type": "integer"
            },
            "description": "Max requests allowed in the current window for this bucket."
          },
          "X-RateLimit-Remaining": {
            "schema": {
              "type": "integer"
            },
            "description": "Requests remaining in the current window."
          },
          "X-RateLimit-Reset": {
            "schema": {
              "type": "integer"
            },
            "description": "Epoch seconds when the window resets."
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "examples": {
              "rate_limited": {
                "value": {
                  "error": "Rate limit exceeded. Retry after the window resets.",
                  "code": "rate_limited"
                }
              }
            }
          }
        }
      },
      "InvalidQueryParam": {
        "description": "A query parameter was present but invalid. Malformed values are rejected rather than silently defaulted; a well-formed value outside the allowed range is still clamped (for example `limit=999` clamps to 100).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "examples": {
              "invalidObject": {
                "summary": "Unknown pipeline-stage object",
                "value": {
                  "error": "Invalid 'object' (expected one of: lead, company)"
                }
              },
              "invalidLimit": {
                "summary": "Non-numeric limit",
                "value": {
                  "error": "Invalid 'limit' (expected a whole number)"
                }
              }
            }
          }
        }
      },
      "ServerError": {
        "description": "Unexpected server error \u2014 the request failed on our side (e.g. a database error), not because of the request itself. On a WRITE endpoint some work may already have been applied before the failure, so prefer re-reading state over blindly retrying, or you can end up with duplicates.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "examples": {
              "server_error": {
                "value": {
                  "error": "Failed to list leads"
                }
              }
            }
          }
        }
      }
    }
  }
}
