# Cornersight > Cornersight tracks LinkedIn profiles, company pages, individual posts and keyword searches, turns post engagement (likes & comments) into leads, and enriches them. Discover Influencers also finds, in one-off runs, the people who POST about a topic and get engagement on it. It exposes the same workflows three ways: a Public REST API (X-API-Key), an MCP server (same API key, sent as a header, for ChatGPT/Claude and other agents), and a CLI. The enrichment and post/engagement endpoints are asynchronous — they return a `jobId`; poll job status until `completed` or `failed`. Key facts an agent needs: - Keyword source availability is exposed on GET /api/v1/sources as optional lastRun.postsAvailable (approximate provider first-page totals summed across terms; shared posts may be counted twice and totals can drift) and lastRun.caughtUp (true when every term only returned posts swept earlier). Both are omitted for unmeasured/older runs. The same fields are returned by CLI `sources` and MCP `list_sources`; they are estimates/diagnostics, not promises of unique or capturable posts. - Keyword searches default to engager leads. `mode: "posts_only"` with `postsPerSync: 1..60` instead saves matching post text, captures no people/leads, and charges one credit per NEW kept post on each daily run; AI/expression rejects and repeat posts are free. First ask for a spend quote without `confirmSpend`, then send `confirmSpend: true` only after consent. The public source-posts read returns text; kept-posts is a verdict listing and does not return post text. CLI 4.5.0 adds the matching `--mode posts-only --posts-per-sync N` flags to estimate/track/update; MCP track_keyword/update_keyword accept the same fields. - Keyword searches choose WHO they capture (engagers mode only, migration 176): `captureEngagers` (default true — people who liked or commented) and `capturePostAuthors` (default false — the person who WROTE each kept post, as a lead with `engagementType: "Author"`); at least one must be true, both are refused for `posts_only`. An author is charged exactly like an engager: one credit per NEW person for the search, repeats free (a later post, or the author also liking their own post: two rows, one credit). A post written by a COMPANY PAGE captures no author and is not charged; runs report `lastRun.postAuthorsCaptured` and `lastRun.companyAuthorsSkipped`. With `captureEngagers: false` no reactions or comments are fetched and a run adds at most one new person per kept post; `estimatedDailyMax` is still `creditCap`, the one limit you set; turning engagers back on needs `confirmSpend`. CLI 4.6.0: `--capture-engagers` / `--capture-post-authors` on track-keyword, keyword-update and keyword-estimate; MCP track_keyword/update_keyword/estimate_keyword_search take the same fields. - RAW LEADS (no enrichment, migration 178): any tracked source — person, company page, tracked post or keyword search — can keep its leads RAW with `enrichLeads: false` (default true), sent on POST /api/v1/enrich/profile or /enrich/company with `saveTrackedProfile: true`, POST /api/v1/post/track, POST /api/v1/keyword/track, PATCH /api/v1/profile/{username}, PATCH /api/v1/company/{username} or PATCH /api/v1/keyword/{id} (MCP: `enrichLeads` on the same tools; CLI 4.7.0: `--no-enrich-leads`). A raw source's engagers are captured exactly as before and CHARGED exactly as before — 1 credit per NEW person per source, repeats free, the same balance, caps and errors — but NEVER enriched: no job title, company, country or ICP score, and no enrichment provider call. A raw lead carries the person's LinkedIn URL, their URN (null when unknown), their name when capture recorded one (null when it recorded only a handle or id — an id is never returned as a name), the action (Like, Comment or Author), the comment text, the post's URL, URN and date, and when it was captured. Raw leads are returned ONLY by GET /api/v1/leads/raw (MCP `list_raw_leads`, CLI `leads-raw`); they never appear in GET /api/v1/leads, GET /api/v1/engagers, the dashboard, CSV exports, `lead.detected` webhooks, pushes or integrations. Switching needs no `confirmSpend` (the price is the same) and applies to leads captured or processed AFTER the switch: leads already enriched stay enriched, raw leads stay raw. GET /api/v1/sources reports `enrichLeads` per source. On a raw source's sync status, `enrichment.completed` and `progress.leadsEnriched` count the leads captured raw and charged. - API base URL: https://app.cornersight.io - API auth: header `X-API-Key: cs_` (generate in Cornersight → Settings → API key). The team must have an active subscription or unexpired trial, else `403`. - Error bodies are JSON, always: `{ "error": "…", "code"?: "…" }` — BRANCH ON `code`. A write (`POST`/`PUT`/`PATCH`) whose body is not valid JSON, or is JSON but not an object, is `400` `code: "invalid_json"`, decided before any field is read (an EMPTY body still reads as `{}`, so it gets the route's own "is required"). A path no endpoint serves is `404` `{ "error": "No such endpoint", "code": "not_found" }`; a real path asked with a method it does not serve (e.g. `DELETE /api/v1/credits`) is `405` `code: "method_not_allowed"`, listing the methods it does serve in `allowed` and in the `Allow` header. - Async model: the enrichment and post/engagement POST endpoints return `{ jobId }`; call `GET /api/v1/jobs/{jobId}/status` until `status` is `completed` or `failed`. A failed job carries `errorCode` — a stable value to BRANCH ON — beside `error`, which is human-readable prose whose wording may change. `errorCode` is one of `insufficient_enriching_credits`, `not_found`, `page_out_of_range`, `invalid_request` (all YOURS to act on) or `provider_rate_limited`, `provider_unavailable`, `provider_access_denied`, `internal_error` (all ours, never about your credits). `provider_unavailable` and `provider_rate_limited` clear on retry; `provider_access_denied` does NOT — it is a credential/subscription problem on Cornersight's side and is logged for us to fix. `null` on jobs that failed before the field existed. `error` never contains the upstream provider's raw response. Engagement endpoints (reactions/comments) process synchronously, but they still return a `jobId` — the job is already `completed` (or `failed`) on the first status poll, and the result is ONLY available via that job-status call; never skip it. - Credits: profile enrichment charges one credit per newly enriched person per source; repeat engagement rows for that person, failed attempts, and empty results are not charged. Existing charges are not retroactively refunded. Check the balance with `GET /api/v1/credits` (MCP `get_credits_balance`, CLI `credits`) and the dated/by-source breakdown with `GET /api/v1/credits/usage` (`get_credits_usage` / `credits-usage`). TO RECONCILE A SPIKE use that endpoint's `bySourceId`, not `bySource`: `bySource` names the SYSTEM that charged (worker_sync vs api_enrich_profile), while `bySourceId` names the tracked SOURCE and sums to `totalCharged`. It is the only way to account for credits whose leads you can no longer see — untracking a person, company or post stops GET /api/v1/leads returning its leads while its charges stand, so those credits appear under a source marked `status: "inactive"` rather than vanishing into an unexplainable remainder. - Out of credits: this is NOT an HTTP error. The enrich request is still accepted and returns a `jobId`; the JOB then ends as `failed` with `{ "status": "failed", "error": "Insufficient enriching credits", "errorCode": "insufficient_enriching_credits" }` — so read the job status, don't treat the create call's success as the outcome. Capture keeps running and nothing is lost: the affected leads stay un-enriched and are picked up automatically once the allowance resets or the plan is upgraded. Distinct from `402` (below). - `402 Payment Required`: the team has no active enriching credit plan at all, so it has no allowance (`{ "code": "enriching_plan_not_found" }`, returned by `GET /api/v1/credits`). Fix by putting the team on an active plan. This is NOT the "ran out of credits" case — that surfaces as a failed job (above). - FREE TRIAL (7 days, no card), sized in LEADS per part: Engagement Agent 1,000 people checked, watching up to 20 people (one agent); 2 profiles, 2 tracked posts and 2 keyword searches at up to 250 leads each; 2 influencer (Discover) searches of up to 100 people each. A keyword search past its leads stops with `stoppedBy: "lead_cap"`, and profiles and posts stop collecting at theirs; a source past the slots is `403` `trial_profile_limit`, and an influencer search over 100 people is `400` `trial_profile_limit`. The agent's own sources use neither the trial's slots nor the team's daily keyword ceiling. - Untracking is NOT destructive: `DELETE /api/v1/profile/{username}` and `/api/v1/company/{username}` (MCP `untrack_profile`/`untrack_company`, CLI `untrack-profile`/`untrack-company`) DEACTIVATE the source and keep everything it captured. It cannot delete the row — `leads.tracked_profile_id` is `NOT NULL ... ON DELETE CASCADE`, so a delete would take every captured lead with it, which is the data loss untracking stopped causing. Re-tracking the same username revives that source rather than creating a second one. Credits are NOT refunded: enrichment already charged stays charged and recorded usage does not go down, because usage is an append-only ledger that a delete can never rewrite. Untracking frees the profile slot, not the credits. A profile the team does not track is `404`; a TRIAL profile is `403` (trial profiles cannot be deleted, so the trial cap can't be reset by delete + re-add). - DELETING A SOURCE: WHAT IS STORED AND WHAT YOU CAN READ ARE DIFFERENT QUESTIONS. STORAGE: nothing is erased, on any kind — `leads.tracked_profile_id` is `NOT NULL ... ON DELETE CASCADE`, so the row is deactivated (`status: "inactive"`) and every lead, post and engagement it captured stays in the database. Deleting does not delete data and never has; this is a retention statement, not a privacy one, and erasure is a support request, not an API call. ACCESS: deleting a PERSON, COMPANY or POST source withdraws its data from every read surface BY DEFAULT and by the same rule — `GET /api/v1/leads`, `GET /api/v1/engagers`, the dashboard leads table, the stat cards and the CSV export all exclude it from their all-sources view and answer `404` when it is named by `profileId`/`username`. ⭐ ON THE API THAT DEFAULT IS AN OPT-OUT, NOT A WALL: `?includeInactive=true` on `/leads` and `/engagers` puts untracked sources back in scope on BOTH paths — the all-sources view and `profileId`/`username` — which is how you read back what untracking kept. It is the same flag `GET /api/v1/sources` takes, and it is the ONLY one that does this: `?includeSyncing=true` relaxes READINESS and nothing else. READING IS NOT ACTING — an untracked source still cannot be pushed, and its webhook and ICP configuration still answer `404`. The dashboard has no such opt-out and its delete dialog is unchanged. KEYWORD SEARCHES ARE THE ONE EXCEPTION AND IT IS THE KIND'S, NOT A SURFACE'S — stopping a search stops the sweep and the daily charge and KEEPS its leads readable everywhere leads are read (its own delete dialog promises this), so `/leads` and `/engagers` both keep serving them, by id and in the all-sources view. WHAT IS EXPLICIT: `GET /api/v1/sources?includeInactive=true` enumerates what you USED to track, each row carrying `status: "inactive"`. It is how a deleted person/company/post is discovered so that `/leads?includeInactive=true&profileId=…` can read what it captured, and how a stopped search's id is recovered so its kept leads can be broken down per search. - saveTrackedProfile (enrich endpoints; default false): `false` updates an existing lead and the job FAILS if no compatible lead exists ("Lead not found"); `true` creates the tracked profile/company as a lead source (or brings back one you untracked) and queues its first sync. On a source that is ALREADY tracked and has synced before it applies the settings sent and queues NO sync — re-tracking does not re-sync — and the response says so with `syncId: null` and `syncNotQueuedReason`; `POST /api/v1/sources/{id}/sync` syncs it now. Use `true` before fetching a profile's posts — posts endpoints require the profile to be tracked. - Rate limits: per team (by API key — the same key whether used over REST or MCP), with separate windows for reads and writes. Defaults: 120 `GET` reads/min and 60 writes/min (`POST`/`PUT`/`PATCH`/`DELETE`); raisable on request. ⚠️ THE TWO POSTS READS ARE THE EXCEPTION AND SHARE A THIRD, TIGHTER WINDOW: GET /api/v1/profile/{username}/posts and GET /api/v1/company/{username}/posts fan out to the paid provider, so they draw on ONE per-team budget of 10 CALLS A MINUTE rather than on the 120/min read window — calling both at once spends it twice as fast. ⚠️ THOSE TWO ARE ALSO CHARGED, at ONE CREDIT PER POST RETURNED, and the rate limit is now the SECONDARY guard rather than the only one: 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 telling a customer to buy credits because of a 429 sends them to spend money that would not help. Every `/api/v1/*` response returns `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (epoch seconds). Over the limit returns `429` with a `Retry-After` header (seconds) and body `{ "error": "...", "code": "rate_limited" }`; wait `Retry-After` seconds (or until `X-RateLimit-Reset`), ideally with jitter, then retry. - WRITE idempotency + retrying a failed write: writes are NOT guaranteed atomic — a `500` (our fault) or `502` (a failed upstream call; in practice the database) can land AFTER the change already partially applied. Only `429` (back off per `Retry-After`) and `502` (short backoff) are worth retrying; `4xx`/`503` fail again unchanged. Before ANY write retry, RE-READ state and resend only the part that didn't take. Per endpoint — SAFE to repeat (converge to the same state or short-circuit once done): `PUT /profile|company/{username}/webhook`, every `DELETE` (already-gone→`404`), `POST /enrich/profile`|`/enrich/company` (already-enriched→`creditsCharged: 0`, never double-charges). NEVER blindly retry `POST /keys` — it carries NO idempotency key and each call mints another API key (until the cap→`409`); guard it client-side and only resend after re-reading to confirm the first call's outcome. CAVEAT on the "safe" ones: the async job endpoints (`enrich/*`, `post/*`, `profile/posts`, `company/posts`) mint a NEW `jobId` every call and re-run the provider scrape — lead rows and credits don't double, but you pay a redundant fetch, so dedupe client-side. - MCP server URL: https://mcp.cornersight.io/mcp — TWO WAYS IN, same URL. (1) SIGN IN: give a connector UI (Claude, ChatGPT) just the URL; the unauthenticated request is answered `401` with `WWW-Authenticate: Bearer resource_metadata="https://mcp.cornersight.io/.well-known/oauth-protected-resource/mcp"`, the client runs OAuth 2.1 (dynamic client registration, PKCE) against Cornersight's auth server, and the user signs in and picks the team the connection acts on. Only owners and writer members can authorise one. (2) API KEY: send the SAME `cs_` key as the REST API as a header — either `X-API-Key: cs_` or `Authorization: Bearer cs_` — and no sign-in happens. Prefer the header over the URL: a key in the URL leaks into proxy/access logs, browser history, and `Referer` headers. Clients that support neither can fall back to https://mcp.cornersight.io/mcp/, which authenticates identically — treat that whole URL as a secret; that path never advertises OAuth. A key identifies the team, and revoking it kills the connection. - API key management (was dashboard-only): `GET /api/v1/keys` lists the team's active keys masked (id, name, keyPrefix, createdAt, lastUsedAt — never the plaintext or hash) plus the quota `{ used, max, remaining }`; `POST /api/v1/keys { name }` mints a new key and returns the plaintext `apiKey` ONCE (never retrievable again; `409 max_keys_reached` at the cap); `DELETE /api/v1/keys/{id}` revokes one (soft delete, stops authenticating immediately). A valid key manages its OWN team's keys — a key is a full-team credential, so treat these as incident-response tools too (after a leak, list keys and revoke any prefix you don't recognise). MCP `get_api_keys` is LIST-only; create/revoke are REST + CLI (`keys-list`/`keys-create`/`keys-revoke`) only — agents don't mint credentials. - ⚠️ `linkedinUsername` IS ALWAYS A HANDLE OR NULL on GET /api/v1/leads — never a member URN. Capture keys a handle-less engager's lead on their member URN (LinkedIn serves no vanity slug for some people), but /leads returns ENRICHED leads only and enrichment resolves the public handle from that URN first — so by the time a lead is visible it almost always carries a real handle and a normal vanity `linkedinUrl`. 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 — 6,651 leads, 2.4% of the 277,054 enriched fleet-wide, every one captured before 2026-09-07 — and concentrated: 71 sources hold them and one is 70% URN-keyed. SO A NULL DOES NOT MEAN "this engager had no handle"; it means enrichment ran and could not resolve one. On those rows only, `linkedinUrn` (`ACoAAB1_DY0B-SeJU30N...`) is the identity to key on and `linkedinUrl` reads `https://www.linkedin.com/in/ACoAAB...`, which does NOT resolve as a public profile. Consequences to design around: (a) CRM matching / dedup keyed on the vanity URL misses exactly those rows — match on `linkedinUrn`; (b) a null `linkedinUsername` means "no public handle known", not "no identity" — check `linkedinUrn` before discarding the row; (c) GET /api/v1/engagers keeps WHOLE COUNTS but has NO `linkedinUrn` field at all: it groups a person's handle and member-URN captures into one row (on `linkedinUrn`) so `engagementCount` and the ranking are right, and it reports `linkedin_username` RAW — preferring a handle from any row of that person, falling back to the `ACoAA...` URN only when NO row has one. TODAY THAT FALLBACK IS UNREACHABLE: measured 2026-09-08, 4,444 of 4,445 URN-keyed people have a handled row that wins, and the single exception is unenriched so /engagers (enriched-only) excludes it — zero ACo usernames are served. IT WILL BECOME REACHABLE: those handled twins exist because the OLD capture wrote one person under both spellings, which the current capture no longer does, so a newly captured lead whose handle enrichment cannot resolve will have no handled sibling and WILL be reported under its URN, with no second field to fall back to. Detect it with the `ACo` prefix. The `lead.detected` webhook has the same raw shape. ORGANISATIONS are the one engager we do NOT capture: a company page reacting to a post is not a person, cannot be enriched and cannot receive a connection request, so it never becomes a lead. - CLI: `cornersight-cli` (unscoped, current version v4.14.0; latest published is v4.13.0) — `npm install -g cornersight-cli` or `npx cornersight-cli`; auth via `--api-key cs_`; prints ONE JSON object per run: `{ ok: true, data }` on stdout, where `data` is the API response VERBATIM and is the ONLY path to the payload (nothing inside it is copied to the top level). Only the 7 job commands (`enrich-profile`, `enrich-company`, `profile-posts`, `company-posts`, `post-reactions`, `post-comments`, `company-post-comments`) create a job and poll it to completion by default — they add `jobId` beside `data`, and `--no-wait` returns immediately with the id at `.data.jobId`. EVERY OTHER COMMAND IS ONE SYNCHRONOUS REQUEST (reads, source creation, updates, deletes and `job-status`); they issue no job, and `--no-wait` on them is an unknown option, not a default. `job-status` READS a job's current state rather than waiting for it, and its `status`/`result`/`error` are inside `data` — `.data.status`, `.data.result` — never at the top level. `--help` (command list, or one command's flags and rules) and `--version` (`{"ok":true,"name":"cornersight-cli","version":"..."}`, read from the installed manifest) need no key and make no request. Exit codes: 0 ok, 1 local error, 2 API/job failed. ## Docs - Webhook signature contract (all `lead.detected`, `sync.*` and Test deliveries): `X-Cornersight-Timestamp` is raw Unix epoch seconds, e.g. `1700000000`. Compute HMAC-SHA256 over the exact string `.`; do not parse/reformat the header or re-serialize the body. Reject timestamps more than 5 minutes from your clock. Offline fixture: secret `fixture_secret_not_for_production`; timestamp `1700000000`; raw body `{"event":"sync.completed","timestamp":"2026-01-15T09:30:00.000Z","data":{"state":"completed"}}`; signature `sha256=34e2f8c82d3fe32f7351e121bc8fa8143acdfe7f74c9836fc77671e5429c73f5`. The Node verifier is at `/docs#webhook-security`. - [Overview & authentication](https://app.cornersight.io/docs): base URL, API key, async job model, errors, rate limits. - [API reference](https://app.cornersight.io/docs/api): every endpoint with params & examples, generated from the spec — this is the canonical reference URL, prefer it when citing or linking. The same spec is also served as an interactive Scalar playground at /api-reference (try-it-out; not the canonical link). Spec: [openapi.json](https://app.cornersight.io/openapi.json). - [MCP server](https://app.cornersight.io/docs/mcp): connect ChatGPT/Claude by signing in from the server URL, or with the API key as a header (URL-in-path fallback for clients that can do neither); tool catalog; example prompts. - [CLI](https://app.cornersight.io/docs/cli): install, auth, every command with a usage line, the keyword-search rules, worked examples, output format, exit codes. - [CLI reference](https://app.cornersight.io/docs/cli/reference): every command's FLAGS — type, required, default — plus per-command usage rules, generated from the CLI's own registry. Prefer it when citing a command's arguments; /docs/cli names the commands, this one says what they take. ## How leads are produced (lifecycle: capture -> enrich -> read) - Capture is AUTOMATIC for tracked sources: saveTrackedProfile=true QUEUES a staged background sync that fetches the source's recent posts and captures all likers+commenters as leads, repeating daily. Do not sweep reactions/comments endpoints to collect a tracked source's leads. - The sync is QUEUED, not instant: how long it takes depends on current API load and how many posts/engagements the source has. Leads appear PROGRESSIVELY while it runs, so an empty lead list shortly after tracking does NOT mean it failed. - Track it: the enrich response includes `syncId` when a sync was queued, and `GET /api/v1/profile/{username}/sync` (or `/api/v1/company/{username}/sync`) returns `{ state, description, isFinal, capture: { state, isFinal, completedAt, stoppedBy, creditsSpent, leadRowsCaptured, coverage }, enrichment: { total, pending, completed, failed, skipped }, progress: { postsCollected, engagementsCaptured, leadsTotal, leadsEnriched }, error }`. States: queued | collecting_posts | collecting_engagements | enriching | completed | failed | paused. Poll until `isFinal` is true. NEVER conclude capture is broken without checking this. ⭐ `capture.stoppedBy` SAYS WHY THE RUN ENDED WHERE IT DID and `capture.creditsSpent` WHAT IT WAS CHARGED — the answer to "why did this source come back small", which the progress counts alone could never give: `credits` means the source's own `creditCapPerSync` was reached and there was MORE to collect (raise the limit to get it), `exhausted` means it collected everything it found and a bigger limit changes nothing. ⭐ `capture_empty` means it collected POSTS and captured NOBODY from them — a CAPTURE failure, never a quiet week: between 12 and 14 September 2026 the upstream engager endpoints answered 200 with no rows for three days and every sync in the product reported `completed` with `error: null`. `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 a full page (50) still declared, and no cap was the reason — the capture has materially fewer people than the post declares and NO LIMIT GETS THE REST. Measured 29 September 2026: a post declaring 3,490 reactions stopped at about 1,100 people. `capture.coverage` is `{ declared, captured }` side by side — the reactions + comments the provider declared on the posts the run swept, and the leads this source now holds on them (cumulative; people, so a fully captured post still reads a little under). The rule counts rows the provider SERVED, anonymous reactions included, so a post served in full still ends `exhausted`; it is the same one-page rule POST /api/v1/post/reactions uses for `exhausted: false`. Before these, "collected 100 because that was everything" and "collected 100 because 100 was the cap" were the same response. ⚠ `creditsSpent` CHANGED IN THIS RELEASE: it is the credits the run was CHARGED — one per NEW person per source for the leads it created (a repeat engager's row and a lead whose enrichment failed cost nothing), one per post a posts-only watch bought, or what a keyword sweep spent — so the sync endpoint, `lastRun.creditsSpent` and GET /api/v1/credits/usage agree. Enrichment charges after the capture ends, so while `isFinal` is false it is the charge SO FAR; `null` when the charge cannot be read, never a guess. The lead-ROW count it used to report is `leadRowsCaptured`, and `creditCapPerSync` caps those rows, not unique people. Both are reported on a FAILED run too, because those leads are still enriched and charged. ⚠️ BOTH ARE `null` UNTIL THE FIRST RUN FINISHES, and `stoppedBy` is `null` in three more honest cases: the run FAILED (neither word is true of a run that broke — read `state`/`errorCode`), the source was UNTRACKED mid-run (the TOP-LEVEL `stoppedBy` says `untracked`), or it is a KEYWORD search, whose sweep reports its ending as `lastRun.stoppedBy` on GET /api/v1/sources and never here. The TOP-LEVEL `stoppedBy` on the same response names the SAME ending (it used to be `null` beside `exhausted`): it spells the cap `credit_cap` (published first, and branched on) and reports an untracked run as `untracked`, the one ending `capture` has no word for. `errorCode` uses the job codes plus two a keyword sweep can fail with that are the CUSTOMER'S to fix: `ai_error` (the search's AI model, key or quota — `error` says which, as `lastRun.reason` does) and `search_term_rejected` (the provider refused one of the search's terms with a 400 — `error` names it; edit the keywords, retrying will not help). Both used to read `internal_error`. `nextSyncAt` is `null` for an UNTRACKED source: nothing will run it. - ⚠️ CAPTURE FINISHING IS NOT THE RUN FINISHING, and `state`/`isFinal` describe the WHOLE lifecycle. Capture writes the engagement records; enrichment then adds firmographics one lead at a time for one credit each, and it normally runs on well after collection ends. So a source stays `enriching` with `isFinal: false` while leads are still being enriched and billed, even though its capture run is over — `capture.state`/`capture.isFinal` are the capture half on its own. DO NOT REPORT A SOURCE AS COMPLETE, OR ITS LEAD DATA AS FINAL, WHILE `enrichment.pending` IS ABOVE 0: those leads have no company/title/country yet. `enrichment.completed` is what has been enriched AND charged; `failed` (attempts exhausted) and `skipped` (nothing to add, or no chargeable plan) are terminal and free, and are why a finished run can hold fewer enriched leads than it captured. - Manual reactions/comments calls are targeted single-page pulls (~50 engagers per page, 0-based `page` param) for inspecting one post on demand. - Engager records contain username, profileUrl, entityUrn (the stable `ACoAA…` member id — for an engager with no public handle it is their ONLY identity, and what capture keys their lead on), first/last name, free-text headline, picture, reaction type / comment text — NO structured company, title, or location; enrichment adds those. - Enrichment of captured leads is automatic in the background (1 credit per newly enriched person per source; repeat engagement rows are free; capture is free). POST /api/v1/enrich/profile with saveTrackedProfile=false re-enriches one lead on demand. - Capture depth is set by how deep LinkedIn will page, not by a fixed percentage. Posts of a few hundred reactions capture essentially completely. Very large posts (tens of thousands of reactions) have been measured plateauing near 3,000 captured reactors — there is no ~1,500 ceiling. A post's displayed reaction count and the reactor list LinkedIn actually serves do not always agree, so treat the displayed count as an approximate target, not a quota. Do NOT treat short capture as expected — there is no known fixed shortfall, so investigate it rather than assuming a ceiling. - Don't want to poll? Opt a tracked source into sync events and Cornersight POSTs `sync.completed` (or `sync.failed`) to its webhook URL when a capture run finishes, carrying the same counts as the status endpoint. It fires at CAPTURE completion, so `state` is the capture run's and `isFinal` says whether the whole run is over: `isFinal: false` means credits are still being spent and the run's `lead.detected` deliveries have not fired yet, with `enrichment.pending` saying how much is left. Signed with HMAC-SHA256 over `{timestamp}.{rawBody}` in `X-Cornersight-Signature: sha256=` (+ `X-Cornersight-Timestamp` in Unix epoch seconds, e.g. `1700000000`, and `-Event`/`-Delivery`); verify against the RAW body and reject old timestamps. Retried with exponential backoff (~30s→8.5h, 6 attempts) for transport errors/429/5xx only — a 4xx is not retried. OFF by default, so existing `lead.detected` consumers are unaffected. ⭐ IT ALSO CARRIES `capture: { stoppedBy, creditsSpent, leadRowsCaptured }` FOR A PERSON OR COMPANY SYNC (`creditsSpent` is the charge SO FAR — at capture end enrichment has usually not charged yet, so poll the sync endpoint for the settled figure) — the same fields, under the same key and the same names, the polling endpoint above serves, so you read one path whether you poll or are called. ABSENT on a KEYWORD sweep, which answers both in fields published first: top-level `stoppedBy` and `progress.creditsSpent`. Absent means "read it where that kind reports it", never "it was free". ⭐ THE PAYLOAD NOW SAYS WHY THE RUN ENDED: `stoppedBy` and `reason` ride on `sync.completed`/`sync.failed` for KEYWORD sources (ABSENT, not null, on a person/company sync — that kind has no stop condition of this sort). `stoppedBy` on a completion is `credits`|`post_limit`|`exhausted`|`team_cap`|`lead_cap`; on a failure it is `error`|`ai_error`|`capture_empty`, or null for a run that threw before reaching its loop. ⭐ `capture_empty` IS NEW AND IT IS THE ONE TO BRANCH ON: the sweep HARVESTED REAL POSTS and every engager fetch answered and returned NOBODY. Between 12 and 14 September 2026 the upstream engager endpoints answered 200 with no rows for three days and every run reported itself a clean `exhausted`, so this is now a FAILURE with its own name rather than the `error` it used to hide inside. Read it with `progress.postsCollected` (the N in "harvested N posts, captured nobody") beside it. ⚠️ A SEARCH THAT SIMPLY FOUND NOTHING NEVER REPORTS IT — a run that harvested no posts stays a clean `exhausted` with every counter at 0 — so never tell a user their keywords are too narrow when they were handed this one. A sweep stopped at a CAP is a SUCCESS — `sync.completed` with `error: null` — so `stoppedBy: "credits"` is the only thing in the payload that says a cap cut it short. ⚠️ `reason` IS ALWAYS NULL ON `sync.failed`, deliberately: a failing run's reason is built from the provider's own message and can carry its whole response envelope, while `error` beside it is the SANITISED field. Read `reason` on a completion and `error` on a failure; do not expect both. - WHICH SOURCES SEND SYNC EVENTS: tracked profiles, company pages AND KEYWORD SEARCHES — a keyword sweep writes a sync run like any other source, so its event carries `profileType: "keyword"` with the search's terms as `username` and the same state/isFinal/enrichment fields (set it with `PUT /api/v1/keyword/{id}/webhook` — `syncEvents: true`, by SOURCE ID). TRACKED POSTS DO NOT: a post's webhook IS configured — `PUT /api/v1/sources/{id}/webhook`, like any source — but no lifecycle event is ever sent for a post, so `syncEvents: true` on a post is a 400 with `code: "not_supported_for_post"` rather than a setting that is saved and ignored. LATENCY: the event lands in a durable outbox the instant a run reaches its terminal state and a poller drains it about every 10 seconds, so the first attempt normally arrives 10–20 seconds after the run ends — do NOT conclude an event was lost because it did not arrive instantly, and do not conclude it was lost at all without reading `lifecycleEvent`. `lifecycleEvent` ON THE SYNC STATUS ENDPOINT IS THE ANSWER TO “my callback never arrived”: `{ enabled, lastDelivery: { event, syncId, status, attempts, maxAttempts, responseStatus, lastError, queuedAt, deliveredAt, nextAttemptAt, stalled } | null }`. `enabled:true` with `lastDelivery:null` after a finished run means NOTHING WAS ATTEMPTED; a `lastDelivery.status` of `failed` with a `responseStatus` means the customer's own endpoint rejected it. Those are different problems and used to look identical. `status: "queued"` means enqueued and NOT YET ATTEMPTED (with `queuedAt`); `pending` means attempted and waiting for its next retry; `stalled: true` means queued five minutes or more with no first attempt — a delay on Cornersight's side, which ops is alerted to. AN EXPLICIT LEAD PUSH IS REPORTED HERE TOO when it is the source's newest delivery: `event: "lead.detected"`, `syncId: null`, plus `pushId` and live `counts: { pending, delivering, delivered, failed, held }` (`attempts` = its leads attempted so far, `maxAttempts` = its leads in all; status `held` when the webhook's icpOnly filter took every lead). So `lastDelivery` can be set while `enabled` is false: `enabled` is about sync events only. - `spend.cap_reached` = ONE SEARCH REACHED ITS OWN `creditCap` and the sweep stopped there. Same opt-in (`syncEvents`), same signature, same outbox and same retries as the sync events — nothing extra to configure. Payload `data`: `{ sourceId, username, profileType:"keyword", syncId, cap:"search_credit_cap", scope:"search", capCredits, creditsSpent, teamSpentToday, skipped, reachedAt, reason }`. KEY YOUR RECORDS ON `sourceId`, never on `username` (a search's username is its TERMS — editable, and not unique across a team). ⭐ ONCE PER SOURCE PER UTC DAY, claimed atomically and FAILING CLOSED (if the claim cannot be taken, nothing is sent) — you do NOT need to deduplicate this stream, and the old advice to dedupe on `reachedAt`+`cap` is retired. Five searches hitting their own caps in one day are still five events. Delivered to THAT SOURCE'S webhook only. KEYWORD SOURCES ONLY: a person/company sweep is charged per lead ENRICHED, minutes to hours after capture ends, so it has no per-run spend to report and emits nothing here. - `spend.ceiling_reached` = THE TEAM'S DAILY KEYWORD CEILING BOUND — a DIFFERENT control from the above, with a different owner (an admin, in Settings), a different scope (the whole team) and a different audience, which is why it is a second event name rather than a field on the first. Same payload shape with `cap:"team_daily_ceiling"`, `scope:"team"`, and `skipped: true` with `creditsSpent: 0` for a run that never started because the day was already spent. ⭐ ONCE PER TEAM PER UTC DAY, FANNED OUT to EVERY keyword source on the team with sync events on and a webhook URL set (a PAUSED search still receives it; an untracked one does not). ⚠️ `sourceId`/`username` NAME THE SOURCE WHOSE RUN TRIPPED THE CEILING and are the SAME in every copy — NEVER the recipient: it is one fact delivered to many endpoints, not a personalised event. A search can hit this having spent a fraction of its own cap. Read the day's position any time from GET /api/v1/credits (`spentToday`, `dailyCeiling`, `dailyCeilingMode`); it resets at midnight UTC. - Reading leads: `GET /api/v1/leads` lists captured leads (MCP `list_leads`, CLI `leads-list`) — one row per ENGAGEMENT, filterable and paginated. It is a PULL path: the intended way for leads to LEAVE Cornersight is still the `lead.detected` webhook (below), which pushes each lead once it is fully enriched and needs no polling. Use `/leads` for ad-hoc queries, backfills and reconciliation; use the webhook for the steady state. A source in raw mode (`enrichLeads: false`) is read with `GET /api/v1/leads/raw` instead (MCP `list_raw_leads`, CLI `leads-raw`) — neither `/leads` nor the webhook ever carries a raw lead. The API recipe at the end is a fallback for neither being available. - `lead.detected` webhook = the way leads leave Cornersight. ONE POST PER LEAD (never batched), fired only once the lead is fully ENRICHED — so jobTitle/company/companyDomain/country are already populated on arrival (individually nullable). Payload: `{event:"lead.detected", timestamp, data:{leadId, engagementType("Like"|"Comment"|"Author" — Author is a keyword search's post author), linkedinUsername, linkedinUrl, linkedinUrn(the stored member URN, sent as stored; null when none was captured), avatarUrl, name, jobTitle, company, companyName(same value as company), companyDomain, country, commentText(null for Likes), commentPostedAt(the COMMENT's own time — the provider's, else decoded from the comment's ID; null for Likes and when neither was available), postUrl, postText, postPostedAt(when the POST was published, as GET /api/v1/leads returns it), trackedProfile, isIcp}` plus the company fields below, 26 data fields in all. - `post.detected` webhook = the way a POSTS-ONLY WATCH's posts leave Cornersight. ONE POST PER NEW POST (never batched), fired the moment a posts-only source's daily sync finds a post it has not seen before. Payload: `{event:"post.detected", timestamp, data:{username, profileType("person"|"company"|"keyword"), sourceId, urn, url, text, postedAt, firstSeenAt, creditsCharged}}`. On a keyword source `postedAt` is the instant the post's activity URN encodes (the keyword search result has no time field); it was null before the September 2026 worker release that added it. `creditsCharged` is always 1 — one post is one credit — and is in the body so it need not be taken on trust. ⚠ IT NEEDS ONLY A WEBHOOK URL, NOT the `syncEvents` opt-in: that opt-in exists so existing `lead.detected` consumers never start receiving a shape they do not handle, and a posts-only source has to be created deliberately by somebody who asked for exactly this, so there is no pre-existing subscriber to surprise. It travels the RETRIED outbox that `sync.completed` uses (exponential backoff, ~30s→8.5h, 6 attempts, transport/429/5xx only), not `lead.detected`'s single attempt, because a post that vanishes because an endpoint blipped is recoverable only from GET /api/v1/sources/{id}/posts — which a subscriber who believes they are being pushed to will never think to poll. Signed exactly as every other event is. Dedupe on `data.urn`: a post is fetched, charged and delivered ONCE, ever. - `lead.detected` config is per tracked source, via the API (`GET`/`PUT /api/v1/{profile|company}/{username}/webhook`, and `GET`/`PUT /api/v1/keyword/{id}/webhook` for a KEYWORD SEARCH — PUT is a partial update: webhookUrl, icpOnly, autoSend, syncEvents; a TRACKED POST is configured by source id with `GET`/`PUT /api/v1/sources/{id}/webhook`, where 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` — and `syncEvents: true` is refused with a 400 `not_supported_for_post`, because a post sends no lifecycle events; MCP `get_source_webhook_config`/`set_source_webhook_config`, CLI `source-get-webhook`/`source-set-webhook`) OR the dashboard (Integrations). EVERY SURFACE HAS BOTH PAIRS, split the same way the routes are: MCP `get_webhook_config`/`set_webhook_config` and CLI `get-webhook`/`set-webhook` (plus `company-get-webhook`/`company-set-webhook`) for a person or company page, and MCP `get_keyword_webhook_config`/`set_keyword_webhook_config` and CLI `keyword-get-webhook`/`keyword-set-webhook` for a keyword search. Over MCP the search's id comes from `list_sources`, which shows only the first ten sources and cannot page, so on a larger team read the id from the CLI or REST first. ⚠️ A KEYWORD SEARCH IS ADDRESSED BY SOURCE ID, not by its keyword text: its `id` from `GET /api/v1/sources` is the identifier, while its `username` there is the free-text terms it searches for, so the username-keyed routes cannot take it. Its webhook is otherwise identical — same column, same delivery, and a keyword search's `lead.detected` has always fired; only reading and setting it used to be dashboard-only. ITS `/icp` AND `/sync` WORK THE SAME WAY — `GET`/`PUT /api/v1/keyword/{id}/icp` and `GET /api/v1/keyword/{id}/sync`, both by source id, and both have the same three surfaces the webhook does: MCP `get_keyword_icp_config`/`set_keyword_icp_config` and `get_keyword_sync_status`, CLI `keyword-get-icp`/`keyword-set-icp` and `keyword-sync-status`. All three were dashboard-only for one reason (the username-keyed routes cannot take a keyword's free-text name) and none was ever person/company-specific underneath: capture scores a keyword search's leads against its ICP rules already, and a keyword sweep writes a sync job like any other source. Hand a source id or a keyword's text to a username-keyed route and the error now names the source you actually hold and the route that takes it, instead of "Profile not tracked". Each source has a SINGLE webhook URL plus an ICP-only toggle (delivers only ICP-matching leads). An auto-send toggle (ON by default) controls whether FUTURE leads queue automatically. URL must be a public http(s) host (localhost/private/link-local/.internal/.local are rejected on save and blocked at delivery). - `lead.detected` fires from the initial sync, the DAILY sync, and a background poller that sweeps enriched-but-undelivered leads (so a crashed/credit-paused sync still delivers). NOT tied to sync completion — leads arrive progressively. - `lead.detected` delivery: 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 one slow endpoint or big backlog cannot hold up anyone else's deliveries: the first attempt for a newly queued lead (a push included) normally lands within about a minute. Ordering is best-effort, NOT guaranteed. At-least-once — dedupe on `leadId`. - `lead.detected` company fields: `companyUrl`, `companyLinkedinUrl`, `companyIndustry`, `companyEmployeeCount`, `companyStaffRange`, `companyDescription` and `companyLocation` are attached before the lead is delivered. 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. companyDescription and companyLocation (headquarters) come from the company record, resolved once per company and cached for 6 months, at no enriching-credit cost. The payload has no `companyEnrichedAt`; if the provider could not answer for a company yet the fields arrive null, and `companyEnrichedAt` on GET /api/v1/leads stays null for that lead until the company is resolved. - `lead.detected` IS SIGNED, with the same scheme and the same per-team secret as sync events and the dashboard's test send (the Test button in the Integrations panel — on the Leads page, opened from the table's "+" menu → Webhook or the Webhook column → Edit webhook) — one verifier covers all of them. The signing secret (starts `whsec_`) is revealed in that same Integrations panel next to the Test button; a customer needs it to verify signatures. Headers: `X-Cornersight-Signature: sha256=`, `X-Cornersight-Timestamp` (Unix epoch seconds, e.g. `1700000000`), `X-Cornersight-Event`, `X-Cornersight-Delivery` (unique per ATTEMPT — dedupe on `data.leadId`, not this). Signature = HMAC-SHA256 over the exact string `{timestamp}.{rawBody}` using the timestamp header's raw text (no parsing or reformatting); verify against the RAW body before parsing (re-serializing JSON can reorder keys and will fail). Reject timestamps more than 5 minutes from your clock to reduce replay risk. The timestamp is inside the signed material, so a captured body can't be replayed under a fresh header. Offline fixture: secret `fixture_secret_not_for_production`; raw timestamp `1700000000`; exact raw body `{"event":"sync.completed","timestamp":"2026-01-15T09:30:00.000Z","data":{"state":"completed"}}`; expected `X-Cornersight-Signature: sha256=34e2f8c82d3fe32f7351e121bc8fa8143acdfe7f74c9836fc77671e5429c73f5`. - ⚠️ `lead.detected` still differs from sync events in ONE way that matters: its retries are SHORT. A transport error, 408, 429 or 5xx is retried within the same sweep — 3 attempts in all, ~1s then ~4s apart — and a 4xx or redirect is not retried; after that the lead is `failed` and is NOT retried again automatically (return 2xx as soon as you durably accept, then process async). Sync events retry with backoff for hours. RE-PUSH IS THE RETRY, and it is now a public operation: `POST /api/v1/profile/{username}/push` (MCP `push_leads`, CLI `push-leads`) with `scope: "all"` re-queues the source's delivered AND failed leads; filter down to the failures first with `GET /api/v1/leads?webhookStatus=failed` if you only want those. - BACKFILL — THE ANSWER IS CONDITIONAL, and reading it as an unqualified yes is the single most common surprise here. NOTHING IS DISCARDED: leads captured while no webhook existed are kept, deliverable forever, and replaying them re-enriches nothing, so a backfill costs ZERO enriching credits. But SAVING A WEBHOOK DOES NOT ALWAYS SEND THEM. • `PUT /api/v1/profile/{username}/webhook` (and the company / keyword forms, and their MCP + CLI equivalents) NEVER delivers history. It writes configuration and touches no lead, whatever the URL was before. This is the case that reads as “the webhook is broken”: new keyword/profile leads arrive normally because those go through auto-send, while the cohort captured BEFORE the save sits untouched — which is correct, not a fault. • The DASHBOARD's Integrations panel does backfill, but only on a FIRST-TIME URL add and only while auto-send is on. Replacing an existing URL, or saving one with auto-send off, delivers nothing until asked. • THE SUPPORTED WAY TO SEND HISTORY IS AN EXPLICIT PUSH: `POST /api/v1/profile/{username}/push`, `POST /api/v1/company/{username}/push`, `POST /api/v1/keyword/{id}/push`, and `POST /api/v1/sources/{id}/push` for ANY kind by source id — the only push for a TRACKED POST (MCP `push_leads` / `push_keyword_leads` / `push_source_leads`; CLI `push-leads` / `company-push-leads` / `keyword-push-leads` / `source-push-leads`). It works regardless of `autoSend` — that flag governs FUTURE leads only — and is scoped `all` or `icp`, optionally within a `since`/`until` window over the lead's `detectedAt`. ## Discover Influencers (find the people who POST about a topic) - WHAT IT IS: a ONE-OFF search for the people who POST about a topic and get engagement on it — not a recurring search, and not the people who react (that is a keyword search). 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). REST `POST /api/v1/discover` (create), `GET /api/v1/discover` (list), `GET /api/v1/discover/{id}` (one run + its influencers), `DELETE /api/v1/discover/{id}` (soft delete); MCP discover_influencers, list_discover_runs, get_discover_run, delete_discover_run; CLI `discover`, `discover-list`, `discover-get`, `discover-delete`. - TOPIC: a plain list of 1-10 keywords (`keywords`), and a post matching ANY of them counts. There is NO AND/NOT on Discover. Each keyword is a separate LinkedIn search. No quotation marks or brackets inside a keyword; the same keyword twice is a 400. - WINDOW AND ORDER: every run looks back ONE MONTH (fixed, not configurable) and sorts by RELEVANCE, not newest first, so its posts come from across the month. Roughly 250-700 posts are read per keyword (provider-dependent). - ENGAGEMENT: likes (every reaction type) + comments per post; reposts and views are NOT counted. A person's AVERAGE = the sum over their matching posts / the number of their matching posts (usually 1 post, so it equals that post). `minEngagement` is applied to the AVERAGE. HIGHEST ENGAGEMENT = likes + comments on their best matching post — what the Influencer Leads table shows and ranks by, and `highestEngagement` on `GET /api/v1/discover/{id}`. - COMPANY PAGES ARE SKIPPED: only people are found and added. - MAXIMUM (`maxInfluencers`, 1-500; up to 100 on a free trial, which has 2 searches): the most people a 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` (dashboard: "N more people qualified, but the run stopped at its limit of M"). - COUNTRIES (optional `countries`, up to 20): only people whose LinkedIn profile is in one of them are added. Matching ignores case, spacing and accents; knows short forms (UK/GB/England/Scotland/Wales/Northern Ireland -> United Kingdom, US/USA -> United States, UAE -> United Arab Emirates, Holland -> Netherlands, Deutschland -> Germany, Czechia -> Czech Republic, Turkiye -> Turkey, NZ -> New Zealand, KSA -> Saudi Arabia); finds a country inside a full location ("South Delhi, Delhi, India" is India); whole words only ("India" never matches "Indiana"). Bare "America" and "Korea" are NOT aliases. Saved cleaned ("uk" -> "United Kingdom"); an entry with no letters is a 400. Profiles are looked up BEFORE anyone is added, best average first, at most min(500, max(100, 5 x maxInfluencers)) lookups per run; a person with no country on their profile is NOT added. Lookups cost the customer nothing. The run reports `countryChecked` and `countryMatched`. - CREDITS: 1 credit per influencer added, charged ONCE (not daily). Reading posts and country lookups are free. A create without `"confirmSpend": true` is a 409 `spend_confirmation_required` with `estimatedCredits` (= maxInfluencers) and creates NOTHING — say the figure to the person, wait for a yes, then re-send the identical body with `"confirmSpend": true`. - LIFECYCLE: `state` is `running`, then `done` (the dashboard shows "No results" when `found` is 0) or `failed`. Most runs finish within a few minutes. Each influencer: `name`, `linkedinUrl`, `avatarUrl`, `jobTitle`, `company`, `country` (filled by enrichment shortly after capture — null in the API and "Enriching..." in the dashboard until then), `highestEngagement`, `avgEngagement`, `postCount`, `totalEngagement`, `topPostUrl`, `topPostText`; the run carries its `keywords` (the topic/search). DELETE is a SOFT delete: the run and its influencers leave the lists, the Author leads already added stay in your leads (`GET /api/v1/leads?engagementType=Author`), no credit is refunded, and a run in progress stops at its next checkpoint. A trial run cannot be deleted (403). In Influencer Leads, Track adds a person as a tracked profile. - IN THE DASHBOARD: Discover Influencers -> 1 Topic (add keywords) -> 2 Engagement (Minimum average engagement) -> 3 Location (optional countries) -> 4 Results (Maximum influencers) -> Find influencers. Your Searches lists each run with its state, View Leads (opens Influencer Leads for that search) and Delete; tick several rows to delete them together. - WORKED EXAMPLE (REST): `POST /api/v1/discover` `{"keywords":["claude code","ai agents"],"minEngagement":50,"maxInfluencers":25,"countries":["UK"]}` -> 409 `{"error":"This run can use up to 25 credits: ...","code":"spend_confirmation_required","estimatedCredits":25}`; the same body plus `"confirmSpend":true` -> 201 `{"id":"7c9e6679-...","keywords":["claude code","ai agents"],"minEngagement":50,"maxInfluencers":25,"countries":["United Kingdom"],"state":"running","candidates":null,"qualified":null,"found":0,...}`; `GET /api/v1/discover/{id}` a few minutes later -> `{"run":{...,"state":"done","candidates":412,"qualified":31,"found":25,"countryChecked":125,"countryMatched":25},"influencers":[{"name":"Jane Doe","linkedinUrl":"https://www.linkedin.com/in/jane-doe","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":"..."}]}`; `DELETE /api/v1/discover/{id}` -> `{"ok":true,"id":"7c9e6679-...","deleted":true}`. - WORKED EXAMPLE (MCP): discover_influencers `{ keywords: ["claude code","ai agents"], minEngagement: 50, maxInfluencers: 25, countries: ["UK"] }` -> tool error 409 with `details.estimatedCredits: 25`; after the person agrees, the same call with `confirmSpend: true` -> the run; then list_discover_runs `{}`, get_discover_run `{ id }`, delete_discover_run `{ id }` answer exactly as the REST routes do. - WORKED EXAMPLE (CLI 4.11.0+): `cornersight discover --api-key cs_ --keywords '["claude code","ai agents"]' --min-engagement 50 --max-influencers 25 --countries '["UK"]'` exits 1 and sends NOTHING, printing "This run can use up to 25 credits, once, not daily..."; add `--confirm-spend` to start it. Then `cornersight discover-list --api-key cs_`, `cornersight discover-get --api-key cs_ --id `, `cornersight discover-delete --api-key cs_ --id `; each prints `{ ok: true, data }` with the API response as `data`. ## API endpoints — leads & enrichment (all POST unless noted; header X-API-Key required) - POST /api/v1/enrich/profile:enrich a LinkedIn person profile. Body: `{ "username": "", "saveTrackedProfile?": false, "creditCapPerSync?": null }` — true = create the tracked profile (or bring back an untracked one) and queue its first sync; on a source that has ALREADY synced, no sync is queued and the response says so (`syncId: null` + `syncNotQueuedReason`; POST /api/v1/sources/{id}/sync syncs it now); false = update an existing lead only. Async (returns jobId). Tracking is automatic end to end: saveTrackedProfile=true QUEUES a staged background sync that captures the profile's recent posts and ALL their engagement (reactions + comments) as leads — do NOT hand-roll per-post reactions/comments sweeps to collect a tracked profile's leads; those endpoints are for immediate, targeted single-page pulls. Duration is not fixed; poll GET /api/v1/profile/{username}/sync for the stage and progress. ⭐ `creditCapPerSync` BOUNDS WHAT ONE SYNC OF THIS SOURCE MAY SPEND — a whole number of leads collected (1 credit = 1 lead), or `null` for no limit, which is what every source without one has. PER SYNC AND NOT A LIFETIME TOTAL: the source re-syncs about every 24 hours and this bounds EACH of those runs. Needs `saveTrackedProfile: true`; sending it without is a 400, not a dropped field. ⚠ RENAMED from `creditCap` in this release and NOT aliased: `creditCap` now names ONLY a keyword search’s per-RUN cap, so sending it here is a 400 with `code: "renamed_field"`. AND IT IS EDITABLE: calling this endpoint again for a source the team already tracks applies the value it carries (and PATCH /api/v1/profile/{username} changes it without re-syncing), so a limit chosen blind can be corrected after watching a sync. Omitted leaves it as it is, `null` clears it. 0, fractions and values past 2147483647 are refused. The 200 ECHOES `creditCapPerSync` back when the body named it. Read it back as `creditCapPerSync` on GET /api/v1/sources; a sync it stopped reports `stoppedBy: "credit_cap"` on GET /api/v1/sources/{id}/sync. PER SYNC, EVERY SYNC — it bounds EACH sync (the capture cap counts lead rows, while billing charges once per new person per source), not the first pull only and not a lifetime total, and it has NO ESTIMATE, NO CONFIRMATION GATE AND NO TEAM CEILING: nothing prices a sync before you set it, raising or lowering it never needs `confirmSpend`, and the team's `dailyCeiling` does not count a credit of it. ⚠️ NOT a keyword search's `creditCap`, which HAS all three — that is the daily bound on a RECURRING SWEEP, priced by POST /api/v1/keyword/estimate as `estimatedDailyMax`, gated by `confirmSpend` (409 `spend_confirmation_required`) and stopped by the team's `dailyCeiling`, which counts KEYWORD SPEND ONLY. ⭐ IT CAN ALSO CREATE A POSTS-ONLY WATCH. `mode: "posts_only"` with `postsPerSync` (1-60) makes this source fetch NEW POSTS ONLY on its daily sync — no engagers, no leads, no enrichment — at ONE CREDIT PER NEW POST, up to `postsPerSync` a day, and NEVER TWICE FOR THE SAME POST. It NEVER BACKFILLS: the first sync buys up to `postsPerSync` 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. 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, and the second is `engagers`, the default, which charges per LEAD. ⚠ A posts-only create NEEDS `confirmSpend`: without it it is refused 409 `spend_confirmation_required` carrying `mode`, `postsPerSync`, `estimatedDailyMax` (equal to `postsPerSync`), `daysToExhaustAtCap` — both from the SAME shared estimate function every other spend surface quotes — and `remainingBalance`, and NOTHING is created. Say the daily figure, wait for a yes, re-send with `confirmSpend: true`. This is a STANDING charge until the source is untracked. ⚠ `mode` and `postsPerSync` need `saveTrackedProfile: true`; sending them without it is a 400, not a dropped field. Sending them for a source you ALREADY track CHANGES its mode, which is how it is edited — and SWITCHING AN EXISTING POSTS-ONLY SOURCE TO `engagers` IS A SPEND INCREASE that needs `confirmSpend`, while switching the other way never does (PATCH /api/v1/profile/{username} does the same). A posts-only source's new posts arrive as `post.detected` webhooks, are readable at any time at GET /api/v1/sources/{id}/posts, show a "Posts only" badge on the profile row in the dashboard, and report `mode`, `postsPerSync` and a `lastRun` carrying `postsFetched` and `creditsSpent` on GET /api/v1/sources. `creditCapPerSync` applies as well and the TIGHTER of the two binds a run, because for this mode one post IS one credit. ⭐ `firstSyncPosts` (1-50: the latest N posts) and `firstSyncDays` (1-90: posts of the last N days, at most 50) choose what an engagers source's FIRST sync collects instead of the latest 15 — later syncs still check the 4 newest, engagers are charged as usual; `null` = default, needs `saveTrackedProfile: true`, refused with `mode: "posts_only"`, read back on GET /api/v1/sources. - POST /api/v1/enrich/company:enrich a LinkedIn company page. Body: `{ "username": "", "saveTrackedProfile?": false, "creditCapPerSync?": null }` — same semantics (tracking auto-captures the page’s posts + engagement in the background, and `creditCapPerSync` means exactly what it means above, editable by calling again or with PATCH /api/v1/company/{username}). Async. PER SYNC, EVERY SYNC — it bounds EACH sync (the capture cap counts lead rows, while billing charges once per new person per source), not the first pull only and not a lifetime total, and it has NO ESTIMATE, NO CONFIRMATION GATE AND NO TEAM CEILING: nothing prices a sync before you set it, raising or lowering it never needs `confirmSpend`, and the team's `dailyCeiling` does not count a credit of it. ⚠️ NOT a keyword search's `creditCap`, which HAS all three — that is the daily bound on a RECURRING SWEEP, priced by POST /api/v1/keyword/estimate as `estimatedDailyMax`, gated by `confirmSpend` (409 `spend_confirmation_required`) and stopped by the team's `dailyCeiling`, which counts KEYWORD SPEND ONLY. ⭐ IT CAN ALSO CREATE A POSTS-ONLY WATCH. `mode: "posts_only"` with `postsPerSync` (1-60) makes this source fetch NEW POSTS ONLY on its daily sync — no engagers, no leads, no enrichment — at ONE CREDIT PER NEW POST, up to `postsPerSync` a day, and NEVER TWICE FOR THE SAME POST. It NEVER BACKFILLS: the first sync buys up to `postsPerSync` 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. 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, and the second is `engagers`, the default, which charges per LEAD. ⚠ A posts-only create NEEDS `confirmSpend`: without it it is refused 409 `spend_confirmation_required` carrying `mode`, `postsPerSync`, `estimatedDailyMax` (equal to `postsPerSync`), `daysToExhaustAtCap` — both from the SAME shared estimate function every other spend surface quotes — and `remainingBalance`, and NOTHING is created. Say the daily figure, wait for a yes, re-send with `confirmSpend: true`. This is a STANDING charge until the source is untracked. ⚠ `mode` and `postsPerSync` need `saveTrackedProfile: true`; sending them without it is a 400, not a dropped field. Sending them for a source you ALREADY track CHANGES its mode, which is how it is edited — and SWITCHING AN EXISTING POSTS-ONLY SOURCE TO `engagers` IS A SPEND INCREASE that needs `confirmSpend`, while switching the other way never does (PATCH /api/v1/company/{username} does the same). A posts-only source's new posts arrive as `post.detected` webhooks, are readable at any time at GET /api/v1/sources/{id}/posts, show a "Posts only" badge on the profile row in the dashboard, and report `mode`, `postsPerSync` and a `lastRun` carrying `postsFetched` and `creditsSpent` on GET /api/v1/sources. `creditCapPerSync` applies as well and the TIGHTER of the two binds a run, because for this mode one post IS one credit. ⭐ `firstSyncPosts` / `firstSyncDays` choose what the page's FIRST sync collects, exactly as above. - GET /api/v1/profile/{username}/urn:resolve a public LinkedIn handle to the MEMBER ID the keyword targeting filters take → `{ username, memberId, urn, creditsCharged: 0 }`. THE FILTERS NEVER ACCEPT A HANDLE. `fromPerson` and `mentionsPerson` take a member id — `"AC"` plus base64url, about 39 characters, e.g. `ACoAAA5eqCEBzmkNfjyOp9_MseBpRQ-P17SuIos` — bare or wrapped as `urn:li:person:`; both spellings are accepted and stored as the wrapped one. `authorCompany`/`fromCompany`/`mentionsCompany` take a NUMERIC organisation id (`1441` or `urn:li:organization:1441`) and `authorIndustry` a numeric industry id (`96` or `urn:li:industry:96`). `authorKeyword` is the one that is plain words. ANYTHING ELSE IS A 400 `invalid_urn` carrying `field`, `value`, `expected` and `example` — on POST /api/v1/keyword/track, POST /api/v1/keyword/estimate and PATCH /api/v1/keyword/{id} alike. Before that guard shipped, `fromPerson: ["jasonlemkin"]` was a 201 whose every daily sweep then failed at the provider and was reported to you as a retryable outage. ⚠ THIS LOOKUP IS FREE AND CHANGES NOTHING: it enriches nobody, tracks nobody, queues no sync and charges no enriching credits — `creditsCharged` is 0 in every response. DO NOT use POST /api/v1/enrich/profile to resolve an id: without `saveTrackedProfile` it 404s for anyone who is not already one of your leads, and with it, it TRACKS them, which queues a full sync and charges. The member id is also what a profile enrichment result already carries as `entityUrn`, and what a company enrichment carries as its own `entityUrn` for the company filters. A 404 `not_found` means the provider served no member id for that handle (private, renamed or deleted) and retrying will not change it; a provider outage is a 502 with its own code. MCP `get_profile_urn`; CLI `profile-urn`. - GET /api/v1/profile/{username}/posts:read a public profile's own posts WITH THEIR TEXT, for a profile you have NOT tracked → `{ username, posts: [{ urn, url, text, postedAt, postedAtTimestamp, totalReactionCount, commentsCount, contentType }], creditsCharged, captured: false, maxDepth }`. ⚠ ONE POST IS ONE CREDIT, AND THIS ENDPOINT USED TO BE FREE. Verified 20 September against `ericosiu`: 60 posts across four pages, credits used unchanged at 38,017 before and after, nothing in sources, usage or any dashboard page. That is over — every page of a walk is a different request and so a different cache key, so it was an uncharged fan-out to a paid upstream bounded only by a rate limit. ⚠ ASK TWO THINGS BEFORE FETCHING ANYTHING: HOW MANY POSTS do they 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 (POST /api/v1/enrich/{profile,company} with `saveTrackedProfile: true`, `mode: "posts_only"` and `postsPerSync`), which costs up to that many credits EVERY DAY until it is untracked. ⚠ WITHOUT `confirmSpend` THE CALL IS REFUSED 409 `spend_confirmation_required` AND NOTHING IS FETCHED OR CHARGED. The refusal is the useful part: it carries `estimatedCredits` (equal to `posts`), `remainingBalance` and the sentence to read out. SAY THE COST AND WAIT FOR A YES, then re-send the identical request with `confirmSpend: true` — the round trip IS the consent. ⚠ 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. ⚠ A BALANCE BELOW THE REQUEST IS A 402 `insufficient_enriching_credits` NAMING BOTH NUMBERS, and nothing is fetched — the read is refused WHOLE rather than served short, because a partial answer is not the call that was confirmed. ⚠ 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. The 15-per-page walk still happens, internally, and `maxDepth` is still how far into a history this read goes. An integration written against the free surface is TOLD rather than silently charged. ⚠ THE CHARGE LANDS IN THE ORDINARY 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`. ⚠ IT IS STILL A READ AND IT PERSISTS NOTHING: no tracked source, no queued sync, no post row, no lead, and NO ENGAGERS — a tracked sweep finds posts through this same provider call and then fans out across the reactions and comments on each one, and that fan-out is where a sweep's cost and ALL of its leads come from. `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, which answers 404 for an untracked handle because its job UPSERTS every post it walks against a tracked source — that is the whole reason this endpoint exists. ⚠ 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 — out-of-credits is the 402 and has its own code; the two are different answers. AN EMPTY `posts` ARRAY IS A REAL ANSWER and costs nothing (that profile has no posts we can read), deliberately not a 404, which would be indistinguishable from a mistyped handle; a provider that will not answer is a 502 with its own code and charges nothing either. `text` is null — never missing — for a post the provider served without any, normal for an image or video post. MCP `get_profile_posts`; CLI `profile-posts-read` (needs cornersight-cli >= 4.3.0 — /docs/cli marks it UNRELEASED until npm carries it, and an older install still sends the free surface's `--limit`, which is now a 400). - GET /api/v1/company/{username}/posts:read a public COMPANY PAGE's own posts WITH THEIR TEXT, for a page you have NOT tracked → `{ username, posts: [{ urn, url, text, postedAt, postedAtTimestamp, totalReactionCount, commentsCount, contentType }], creditsCharged, captured: false, maxDepth }`. ⚠ ONE POST IS ONE CREDIT, AND THIS ENDPOINT USED TO BE FREE. Verified 20 September against `ericosiu`: 60 posts across four pages, credits used unchanged at 38,017 before and after, nothing in sources, usage or any dashboard page. That is over — every page of a walk is a different request and so a different cache key, so it was an uncharged fan-out to a paid upstream bounded only by a rate limit. ⚠ ASK TWO THINGS BEFORE FETCHING ANYTHING: HOW MANY POSTS do they 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 (POST /api/v1/enrich/{profile,company} with `saveTrackedProfile: true`, `mode: "posts_only"` and `postsPerSync`), which costs up to that many credits EVERY DAY until it is untracked. ⚠ WITHOUT `confirmSpend` THE CALL IS REFUSED 409 `spend_confirmation_required` AND NOTHING IS FETCHED OR CHARGED. The refusal is the useful part: it carries `estimatedCredits` (equal to `posts`), `remainingBalance` and the sentence to read out. SAY THE COST AND WAIT FOR A YES, then re-send the identical request with `confirmSpend: true` — the round trip IS the consent. ⚠ 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. ⚠ A BALANCE BELOW THE REQUEST IS A 402 `insufficient_enriching_credits` NAMING BOTH NUMBERS, and nothing is fetched — the read is refused WHOLE rather than served short, because a partial answer is not the call that was confirmed. ⚠ 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. The 15-per-page walk still happens, internally, and `maxDepth` is still how far into a history this read goes. An integration written against the free surface is TOLD rather than silently charged. ⚠ THE CHARGE LANDS IN THE ORDINARY 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`. ⚠ IT IS STILL A READ AND IT PERSISTS NOTHING: no tracked source, no queued sync, no post row, no lead, and NO ENGAGERS — a tracked sweep finds posts through this same provider call and then fans out across the reactions and comments on each one, and that fan-out is where a sweep's cost and ALL of its leads come from. `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, which answers 404 for an untracked handle because its job UPSERTS every post it walks against a tracked source — that is the whole reason this endpoint exists. ⚠ `username` IS THE COMPANY SLUG — "instantlyapp" from linkedin.com/company/instantlyapp, and a full company URL is accepted and unwrapped. A linkedin.com/in/… PERSON URL IS REFUSED with a 400 naming the profile read rather than searched for as a company, so you are never quietly told that a real person does not exist — and a refusal charges nothing. ⚠ 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 — out-of-credits is the 402 and has its own code; the two are different answers. ⚠ 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 by postedAt and returns the NEWEST, newest first; the look-ahead is never charged. AN EMPTY `posts` ARRAY IS A REAL ANSWER and costs nothing (that page has no posts we can read), deliberately not a 404, which would be indistinguishable from a mistyped handle; a provider that will not answer is a 502 with its own code and charges nothing either. `text` is null — never missing — for a post the provider served without any, normal for an image or video post. MCP `get_company_posts`; CLI `company-posts-read` (needs cornersight-cli >= 4.3.0 — /docs/cli marks it UNRELEASED until npm carries it, and an older install still sends the free surface's `--limit`, which is now a 400). - GET /api/v1/sources:list the tracked sources (people, company pages, tracked posts and keyword searches) this team monitors → `{ sources: [{ id, username, url, type, displayName, avatarUrl, status, leadsReady, isTrialProfile, lastSyncedAt, createdAt, lastRun, creditCapPerSync, config }], total }`. ⭐ `creditCapPerSync` IS THE PER-SYNC SPEND LIMIT of a person, company page or tracked post — the most credits ONE SYNC may spend, the capture cap counts lead rows, while billing charges once per new person per source — or `null` for no limit, which is the state of every source nobody set one on. Set it with `creditCapPerSync` on POST /api/v1/enrich/{profile,company} or POST /api/v1/post/track, and change it without re-syncing with PATCH /api/v1/profile/{username} or PATCH /api/v1/company/{username}. ⚠ IT WAS CALLED `creditCap` UNTIL THIS RELEASE and was renamed because `config.creditCap` on this same response is a KEYWORD SEARCH’S PER-RUN CAP — one response, two different numbers, one name. The keyword field was published first, so this one moved; there is no alias. It is ABSENT on a keyword search, whose per-run limit is `config.creditCap` — one fact, one place. A sync that ENDED on the limit is reported by GET /api/v1/sources/{id}/sync as `stoppedBy: "credit_cap"`, not here: that is a fact about one RUN. PER SYNC, EVERY SYNC — it bounds EACH sync (the capture cap counts lead rows, while billing charges once per new person per source), not the first pull only and not a lifetime total, and it has NO ESTIMATE, NO CONFIRMATION GATE AND NO TEAM CEILING: nothing prices a sync before you set it, raising or lowering it never needs `confirmSpend`, and the team's `dailyCeiling` does not count a credit of it. ⚠️ NOT a keyword search's `creditCap`, which HAS all three — that is the daily bound on a RECURRING SWEEP, priced by POST /api/v1/keyword/estimate as `estimatedDailyMax`, gated by `confirmSpend` (409 `spend_confirmation_required`) and stopped by the team's `dailyCeiling`, which counts KEYWORD SPEND ONLY. `leadsReady` = readable, NOT "has leads" — on a keyword search it is true from creation; use `lastRun` (`at` null = never swept; `postsScanned`→`postsKept` = the AI filter's before/after; `reason` = WHY it stopped in the words of whatever stopped it — for `ai_error` a sentence CORNERSIGHT OWNS (it used to be the provider's entire JSON response envelope, quoted wholesale; the raw body is now logs-only), with `aiErrorCode` beside it as the token to branch on: `model_not_found` (clear or correct `config.aiModel`), `invalid_key` (save a current key for that provider), `rate_limited` (the key's quota — the next daily run retries), `out_of_credit` (the provider ACCOUNT behind the key has no credit — an HTTP 402, Anthropic's "credit balance is too low", OpenAI's insufficient_quota, xAI's credits exhausted: the owner adds credit or checks billing with the provider; the key is fine, do not tell them to rotate it), `provider_error` (THE PROVIDER'S OWN outage, never the customer's key — do not tell them to rotate one). `aiErrorCode` is present ONLY on an ai_error and absent otherwise. ⚠️ `postsScanned` COUNTS ONLY POSTS THE SEARCH HAD NOT SEEN BEFORE, which is the single most misread number here: a search remembers what it swept, so its FIRST run reads everything it can reach and every run after reports only what appeared since. An established search showing `postsScanned: 1` beside `stoppedBy: exhausted` is working — it means one new post existed, not that the term is broken — and `reason` now says how many matches were already captured. A term that genuinely matches nothing shows 0 scanned with nothing already swept. ⚠ A SMALL `exhausted` WITH NOTHING ALREADY SWEPT IS A REAL SHORTFALL, not a steady-state run: the provider ran out of results after only a few posts, and the keywords page now says so on the search row. ⚠ WORD-COUNT ADVICE RETRACTED AGAIN 2026-09-24. The older 2026-09-09 `sales`/`sales team` contrast did not reproduce as a rule: seven brand-new single-word and phrase searches all reached the 25-post limit searches ran under then, and a new single-word search scanned 179 under a 250-post limit before its credit cap stopped it. 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 stop capture sooner. An established search also remembers swept posts. The provider sees the keyword verbatim; OUR REQUEST DOES NOT VARY WITH TERM LENGTH or word count, and the historical run remains unproven because the 2026-09-09 observation predates `providerRowsDropped`. Read `lastRun.providerRowsDropped`: positive means uncapturable provider rows were skipped; 0 means measured none; absent means unmeasured. The walker continues after a nonterminal all-dropped page; `providerPageLimitReached` flags the 20-page safety bound; false only says that bound did not fire, not that all later pages were empty — under RELEVANCE ordering an all-seen page can stop before a later unseen post. Never relabel `ugcPost`/`share` IDs as `activity` URNs. The dashboard keyword row shows an approximate first-page provider total summed across terms (shared posts may double-count and totals can drift), and a caught-up message when every term ended on posts swept by earlier runs. Those diagnostics are dashboard-only, not fields on this public API response. Do not recommend a longer phrase solely because a single-word search appears short. `postsHarvested` = posts actually reached and captured before the cap stopped it, which can be far below `postsKept`; `engagersSeen`/`engagersDropped`/`engagersDuplicate`/`leadsWritten` explain a run with few leads; `discardedByExpression` (and its older name `postsFilteredOut`, the same number) = how many harvested posts this run's boolean expression discarded, present (AND POSSIBLY 0) only on a search that has one — 0 harvested with 47 discarded is an expression to rewrite, 0 harvested with the key absent is a search that found nothing, and the two used to read identically; discarded posts ARE counted in postsScanned and NOT in postsKept, and kept-posts?include=swept names each one with the clause it failed; when an expression discarded EVERYTHING and every term's walk reached the end of what LinkedIn's search returned, `lastRun.reason` says so ("the search reached the end of what LinkedIn's search returned for its terms and found no exact matches for this search's expression") and advises broadening the terms in a new search — a walk stopped by the 2,000-post scan ceiling, the page bound or already-seen posts makes no such claim; completed AI rejects also have outcome discarded and reason `AI filter rejected this post`, but do not increment the expression-only count — dropped = seen but not a lead, which since 2026-09-08 means an engager with NO identity at all (a handle-less one is now captured under their member URN) or an ORGANISATION page, never a person we lost; duplicate = already captured (free); seen 0 = the capture received nothing. ⚠ seen 0 no longer hides an upstream drop: the pagers used to discard handle-less rows before returning them and tally the loss only to stdout — they now return every row the provider served, so `seen` is what the provider actually gave. All of these are omitted, never 0, for runs predating the fields) to tell never-run from ran-and-found-nothing. Each source's `id` is the `profileId` GET /api/v1/leads filters by (profileId = the capturing SOURCE you monitor, NOT a lead's own profile), and `username` is what DELETE /api/v1/profile|/company/{username} takes — so create (enrich + saveTrackedProfile) / list (here) / filter (leads) / delete now form a closed loop. Optional `?type=person|company|post|keyword` (an unknown value 400s). ⚠ ONLY ACTIVE SOURCES BY DEFAULT: untracking is a soft delete on every kind, and `?includeInactive=true` lists the untracked ones too, each with `status: "inactive"`. That matters for KEYWORD searches, whose leads are KEPT when the search is stopped — without the flag a stopped search's id is undiscoverable, so a per-search sum silently omits it (one team: 96 counted this way against 402 real keyword leads). Listing an untracked person, company or post tells you it existed — THIS LISTS SOURCES, IT DOES NOT SERVE LEADS, and the leads have their own flag of the same name: `GET /api/v1/leads?includeInactive=true` and `GET /api/v1/engagers?includeInactive=true` read back what that source captured and make the id you got here resolve there instead of 404ing. Without it those leads stay out of both endpoints, which is the default and is what the dashboard shows — the dashboard has no such opt-in, so nothing here changes any screen. A keyword source also carries `filters` — the seven provider-side targeting filters (authorIndustry, authorCompany, authorKeyword, fromPerson, fromCompany, mentionsPerson, mentionsCompany) it was created with — present only when at least one is set, so a search using none has no `filters` key and is unchanged. They were write-only before: settable everywhere, returned nowhere. A keyword source ALSO carries `config` — `{ creditCap, captureMode, maxEngagementsPerPost, captureEngagers, capturePostAuthors, aiProvider, aiModel, aiModelEffective, expression, estimatedDailyMax?, daysToExhaustAtCap? }`, the numbers that bound each run, the AI filter that decides which of the posts they find are KEPT, and what one day of the search can cost — present only on keyword sources, and write-only before this for the same reason the filters were. ⭐ WHO A KEYWORD SEARCH CAPTURES READS BACK HERE TOO, all four settings: `mode` (`engagers`|`posts_only`) and `postsPerSync` (posts_only's daily post allowance, null in engagers mode) and `captureReplies` (reply authors captured as engagers, default true) on the source itself, and `config.captureEngagers` (default true) and `config.capturePostAuthors` (default false) inside `config` — both null on a posts_only search, which captures no people. A run that captured post authors also reports `lastRun.postAuthorsCaptured` and `lastRun.companyAuthorsSkipped` (company-page authors, skipped and never charged); both are absent otherwise. `estimatedDailyMax` for a posts_only search is min(postsPerSync, creditCap). A posts_only run that bought its full `postsPerSync` allowance ends `lastRun.stoppedBy: "post_limit"` (the dashboard shows "Daily post limit reached") — raise postsPerSync, not creditCap, to get more; it ends `credits` only when creditCap was the lower of the two and bound first. ⚠ `aiModel` vs `aiModelEffective`: `aiModel` is what the search PINS and is `null` when you never named one, which is a SETTING and not a missing value; `aiModelEffective` is the id the next run will actually send — the pin, or else the provider default (openai gpt-6-luna, grok grok-4.3, gemini gemini-3.5-flash-lite, claude claude-haiku-4-5-20251001). ⚠ `estimatedDailyMax` is the MOST ONE DAY of that search can cost in enriching credits and `daysToExhaustAtCap` the whole days the team's remaining balance funds at that rate — the same two POST /api/v1/keyword/track returns, recomputed from the caps the NEXT run will be bound by. Both are OMITTED, never null and never 0, when there is no honest number (no usable creditCap, or a balance that could not be read), so test for the KEY; `daysToExhaustAtCap: 0` is a real answer and the warning one — the balance cannot fund one whole day, so the next sweep is the one that gets cut short. They are deliberately not collapsed the way `captureMode` is: an omitted model FOLLOWS the default as vendors retire models, a pinned one stays put and the run stops with `ai_error` once that id is gone. All three are `null` when the search has no AI filter. Quote `aiModelEffective` when a search's results look like the wrong model ran. ⚠ `config.expression` IS THE BOOLEAN EXPRESSION the search was created from, in CANONICAL form — operators upper case and the implicit OR written out, e.g. `(hiring AND NOT recruiter) OR fundraising` — and `null` for every search created from a plain keywords list. It is NOT an echo of what was sent: there are no parentheses in the input grammar, so the canonical form is the only place the precedence is visible. `keywords` beside it is always the terms actually searched for, whichever way the search was made. ⭐ A KEYWORD SOURCE ALSO CARRIES `schedule` — `{ runOnce, endAt, maxRuns, runsCompleted, stoppedAt, stoppedReason }` — which is WHEN THE SEARCH STOPS. Always present on a keyword source and on no other kind; every member at its default (runOnce false, the rest null, runsCompleted 0) is the unbounded daily cadence every search created before the feature has, which is an ANSWER and not a missing value. `stoppedAt` set means the scheduler has finished with this search: `nextSyncAt` is null for it, its leads are kept and it stays in your list. `stoppedReason` is one of `run_once`/`end_at`/`max_runs` and is DISTINCT from `lastRun.stoppedBy`, which says how the last RUN ended and is untouched by a schedule ending — a search can stop scheduling after a run that ended `exhausted` with a hundred leads. Raise `maxRuns` above `runsCompleted` with PATCH /api/v1/keyword/{id} to restart one. ⚠ `config` IS WHAT THE NEXT RUN WILL USE; `lastRun.config` IS WHAT THE LAST RUN DID USE, and they disagree whenever the search was edited after that run started. AN EDIT NEVER CHANGES A SWEEP ALREADY RUNNING: the run reads its settings once, when the sweep begins, so an edit made while it is in flight takes effect on the NEXT run — while an edit made before the sweep starts (including after its job is queued) does bind that run. That is why a run reporting `leadsWritten: 21` beside a stored `creditCap` of 5 is not necessarily an overrun: read `lastRun.config.creditCap`, and it is an overrun only if that says 5 too. `lastRun.config` is omitted, never null and never back-filled from the current settings, for runs predating it. `displayName` on a keyword search is its `name`, and it now FOLLOWS A RENAME made through POST /api/v1/keyword/track (a resume carrying a new `name`) or PATCH /api/v1/keyword/{id}: both of those write the column this list reads, which only creation used to write — so a rename returned the new name and this listing went on reporting the old one. Clearing the name (`"name": null`) puts it back to the joined keywords, which is what a create with no name shows. - GET /api/v1/sources/{id}/kept-posts:which posts a keyword run SWEPT AND KEPT — `{ sourceId, scope, include, keptPosts: [{ urn, url, totalReactionCount, commentsCount, contentType, postedAt, postedAtTimestamp, author: { name, url, headline } }] }` (on a run-scoped answer each row also carries `outcome` kept|harvested, `reason` null and its four per-post numbers). ⭐ EVERY ROW SAYS WHAT THE POST IS, so you can triage posts WITHOUT BUYING THEIR ENGAGERS — skip a post with `commentsCount: 0`, spot a viral one, tell a company page's post from a person's by `author` (a person's `headline` usually names their company): `author` { name, url, headline }, `commentsCount`, `totalReactionCount`, `contentType`, `postedAt`, `postedAtTimestamp`, the same names and types GET /api/v1/profile/{username}/posts uses. They are what the keyword search reported when the run found the post (a company page has a name and URL and no headline); `postedAt` is the instant the post's activity URN encodes, because the search result has no time field. Present on EVERY row of every scope and include — null where nothing recorded them: a post the boolean expression discarded (not recorded, to keep each run's record small) and any 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, `null` one the search said nothing about. lastRun says a run scanned 25 and kept 2; this says WHICH 2, the thing that separates "the filter picked quiet posts" from "capture failed" when engagersSeen is 0. `urn` is the activity URN POST /api/v1/post/reactions takes as postUrn, so you can pull those posts' reactions and check — that round trip is the point. `scope` says which question was answered and you MUST read it: "run" is the list that run recorded (the set postsKept counts); "prompt" is the fallback for a run that recorded none — every post kept under the CURRENT prompt across every run of it, a WIDER set that will not match postsKept and is empty for a search with no AI filter. A SEPARATE endpoint because /sources returns every source on every call and an unfiltered run keeps everything it scans — up to 2,000 rows, the most one run scans. `?include=swept` RETURNS EVERY POST THE RUN CONSIDERED instead, under `sweptPosts` (a different key, so a rejected post is never read as a kept one): `[{ urn, url, outcome, reason, engagersSeen, engagersDropped, engagersDuplicate, leadsWritten, totalReactionCount, commentsCount, contentType, postedAt, postedAtTimestamp, author }]`, outcome one of scanned (the run stopped before a complete AI decision or got no verdict), kept (kept and never reached — credit cap, daily post limit or a failed capture — so no stored post and /post/reactions 404s), harvested (captured; the only outcome that can carry non-zero numbers) or discarded (a boolean expression rejected it before AI, or a completed AI filter explicitly rejected it; `reason` names the failed clause, e.g. `missing phrase "sales ops"` or `contains recruiter`, or says `AI filter rejected this post` followed by the provider and model whose verdict it was when recorded, e.g. `AI filter rejected this post (openai, gpt-4o-mini)` — the model answers only keep/reject, so there is no per-post rationale beyond that; `reason` is null on every other outcome; a discarded row's `url` is the post's LinkedIn permalink as the search returned it — or its /feed/update/ permalink when that link could not be stored — while nothing was stored for the post itself so /post/reactions still 404s). ⚠️ A RUN THAT KEPT NOTHING STILL RETURNS ROWS: its `keptPosts` is [] but `sweptPosts` holds one `discarded` row per post its expression or AI filter rejected — never an empty list when anything was discarded. On a run with no AI filter postsScanned = postsKept + discardedByExpression exactly. `unlistedDiscards: { count, reason }` appears on a swept answer ONLY when lastRun.discardedByExpression counts posts the list has no row for: a run recorded before 2026-09-21 (which listed only the survivors and counted only them in postsScanned — the `postsScanned 1, discardedByExpression 9` record) or a list at its 2000-row bound; those rows were never stored and are not reconstructed. THE PER-POST NUMBERS SUM TO lastRun: rows = postsScanned, kept+harvested rows = postsKept, harvested rows = postsHarvested, discardedByExpression counts only expression discards, not AI rejects, and the four sums are engagersSeen/engagersDropped/engagersDuplicate/leadsWritten — which is how "kept 25, wrote 21 leads" becomes "five posts wrote all 21 and twenty wrote none". Ask for it whenever a run produced far fewer leads than posts; `include=kept` is the default and unchanged. On a swept (or a run-scoped kept) answer the four numbers are ALWAYS present including 0; on the older fallbacks they are ABSENT, and an absent number is not a zero. `?include=swept` has NO fallback — a run that recorded no per-post list answers `sweptPosts: null` with a reason. ⚠️ keptPosts is NULL with a `reason`, never [], only on scope "prompt", when nothing recorded a kept list: an unfiltered pre-139 run decides nothing and a pre-fix run recorded nothing, and both KEPT posts. [] appears only on scope "run" and is measured — that run kept nothing. `url: null` = kept but never harvested, so /post/reactions 404s that URN. 404 on a non-keyword source. CLI: `kept-posts` (takes `--id `). - GET /api/v1/sources/{id}/posts:what a POSTS-ONLY WATCH has seen, newest first by when WE saw it → `{ sourceId, username, type, mode: "posts_only", posts: [{ urn, url, text, postedAt, postedAtTimestamp, totalReactionCount, commentsCount, contentType, author: { name, url, headline }, firstSeenAt }], total }`. On a KEYWORD source each post also carries what the search said about it when found — `author`, `commentsCount`, `totalReactionCount`, `contentType` (the profile posts read's names and types) — and `postedAt` is the instant the post's activity URN encodes (the keyword search result has no time field); 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 `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 (older watch posts included — their stored time was guessed from a relative label such as "1d"), `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), and `author` is always null — the watched source is the author. Every key is on every row; a null is never a zero. Optional `?limit=` 1-200 (default 50). A posts-only source pushes each new post as a `post.detected` webhook the moment it finds it, and a webhook is a ONE-SHOT: an endpoint that was down, a URL added after the first sync, or simply wanting last week's posts has no way back to them. This is the poll beside that push, the way /leads is beside `lead.detected`. ⚠ READING IT CHARGES NOTHING — the credit was spent when the post was first fetched and these are the same rows again. ⚠ `firstSeenAt` IS WHEN WE SAW IT, NOT WHEN IT WAS POSTED, and both are returned: they differ by up to a day (the watch syncs about every 24 hours) and only the first explains why a three-day-old post arrived in this morning's callback. ⚠ POSTS-ONLY SOURCES ONLY — 404 `not_posts_only` otherwise, rather than an empty array that would imply the question applies: an engagers source's posts are walked to collect the people who engaged with them and are reported as LEADS (`GET /api/v1/leads?profileId=…`), a keyword search's as verdicts (`/kept-posts`), and a tracked post has no stream of new posts at all. `text` is null — never missing — for a post served without any. - GET /api/v1/leads:list captured leads, one row per ENGAGEMENT (a repeat engager appears once per like/comment — see /engagers below for one row per PERSON). Paginated `{ data, total, limit, offset, hasMore, pendingEnrichment }` with `limit` (1-100) + `offset` (0+). OUT-OF-RANGE IS A 400, NOT A CLAMP: `limit=101`, `limit=0` and `offset=-1` are rejected with a message naming the bound, the same way a non-numeric value and every enum filter already are. Both paginated endpoints behave identically here. ⚠ Returns ENRICHED leads only: capture records name/username/URL, enrichment then adds jobTitle/company/country, and a captured-but-unenriched lead is ABSENT from `data` and `total` rather than returned with blank fields. `pendingEnrichment` counts those absent leads (team + the same profile scoping, ignoring the other filters; always present, 0 = nothing waiting). Enrichment is GATED on an active subscription and credits, so a cancelled or out-of-credits team accumulates pending leads indefinitely — a large number means gated, not stuck; check GET /api/v1/credits. Filters: `profileId`/`username` (scope to one tracked SOURCE), `engagementType` (Like|Comment|Author), `isIcp`, `webhookStatus` (pending|sent|failed|no_webhook), `since`/`until` (ISO 8601), `includeSyncing`, `includeInactive`, `sourceKind`, and case-insensitive substring matches on `name`, `jobTitle`, `company`, `companyDomain`, `country`. `sourceKind=keyword|post|profile|all` scopes the all-sources view by the KIND of source that captured the lead (`profile` = person AND company pages, one kind here; `person`/`company` are a 400 naming `profile`; not combinable with `profileId`/`username`, which already name one source). ⭐ `?sourceKind=keyword&limit=1` + `total` is the ONE-CALL answer to "how many leads have my keyword searches produced" — it spans STOPPED searches, whose leads are kept, so it can exceed any sum over GET /api/v1/sources. ⚠ UNTRACKED SOURCES are out of scope on both paths BY DEFAULT — absent from the all-sources view and a 404 by `profileId`/`username`, matching the dashboard — and `?includeInactive=true` is the opt-out that serves them again on both paths, the same flag and spelling `GET /api/v1/sources` uses. Keyword searches are the deliberate exception and need no flag: untracking one keeps its leads readable. Company fields: `companyName` (also `company`), `companyUrl` (website URL), `companyDomain` (website hostname), `companyLinkedinUrl` (LinkedIn company page), `companyDescription`, `companyIndustry`, `companyLocation` (headquarters), `companyEmployeeCount`, `companyStaffRange` and `companyEnrichedAt`. 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. Unknown facts are null. TWO TIMES, TWO MEANINGS: `postPostedAt` is when the POST was published; `commentPostedAt` (beside `commentText`) is when the COMMENT itself was posted — null for Likes and whenever no comment time could be obtained. It is the provider's comment time when sent, 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 — so Comment leads carry it; one captured before comment times were decoded can be null. It is never filled from `postPostedAt`. - GET /api/v1/leads/raw:list RAW leads — the leads of sources in raw mode (`enrichLeads: false`), captured and charged (1 credit per new person per source, at capture) but never enriched; reading them charges nothing. Paginated `{ data, total, limit, offset, hasMore }` with rows `{ id, sourceId, linkedinUrl, linkedinUrn, name, action, commentText, post: { url, urn, postedAt }, detectedAt }`, newest `detectedAt` first with `id` as the tiebreak. Query: `limit` 1-100 (default 50) and `offset` 0+ (a 400 outside those, not a clamp); ONE source selector — `profileId` (also `trackedProfile` or `source`) or `username`, a 404 when not tracked; `action` Like|Comment|Author (case-insensitive); `since`/`until` ISO 8601 on `detectedAt`, so an incremental pull is `since=`; `includeInactive` exactly as on /leads (untracked sources back in scope; a keyword search's leads are readable either way). Unknown parameters are a 400. `total` counts ENGAGEMENTS, not people. `linkedinUrn` is the member URN (ACoAA…) or null; `name` is null when capture recorded only a handle or id — identify the person by `linkedinUrl`/`linkedinUrn`. No job title, company, country or ICP fields, and no readiness gate: a raw lead is final when written, so it stays listed while its source re-syncs. MCP `list_raw_leads`, CLI `leads-raw`. - GET /api/v1/engagers:the repeat-engagement signal — captured leads aggregated to one row per PERSON with `engagementCount` (every captured row: likes, comments and keyword-search Author rows), `likeCount`/`commentCount`, `postCount` (distinct posts), `firstEngagedAt`/`lastEngagedAt`, plus identity (name, jobTitle, company, country, isIcp). GET /api/v1/leads is one row per ENGAGEMENT (right grain, keeps postUrl/commentText) — so counting /leads rows counts ENGAGEMENTS, not unique people: a repeat engager appears once per like/comment. NEVER report a /leads count (or `total`) as a number of people; use /engagers (one row per PERSON) or its `engagementCount` when you mean people. Rows are one per PERSON even across LinkedIn spellings: a handle capture and a member-URN capture of the same person are MERGED (grouped on the captured `linkedin_urn`) and reported under the handle, so `engagementCount` is whole rather than split. /engagers is the aggregated companion for "who engages MOST with my content" without paging + grouping client-side. Sorted by `engagementCount` desc by default; `orderBy=lastEngagedAt` for recency, `minEngagements=N` for repeat engagers only. Same scope/gates as leads (team-scoped, enriched only, leads_ready sources unless `includeSyncing=true`, live sources unless `includeInactive=true`; `profileId`/`username` to scope to one source). Paginated `{ data, total, limit, offset, hasMore }`. MCP `list_engagers`, CLI `engagers-list`. PAGING: `limit` 1-100 (default 50) and `offset` 0+, both REJECTED with a 400 outside that range rather than clamped. `total` counts the whole FILTERED set and does not depend on the page you asked for: an offset at or past the end is an empty `data` with the total unchanged and `hasMore: false`, exactly as on /leads — until 2026-09-08 it collapsed to `total: 0` there, because the count rides on the rows and an empty page has none, so the last response of a completed sweep reported the collection as empty. `minEngagements` is 0 or more — 0 means no minimum and a negative is a 400. ⚠ UNTRACKED SOURCES are out of scope here on BOTH paths BY DEFAULT, exactly as on /leads — absent from the all-sources view and a 404 by `profileId`/`username` — and `?includeInactive=true` widens both, exactly as on /leads: these are those leads grouped, so the two endpoints take the same flag or they disagree about which sources exist. "Same scope as leads" was a promise this endpoint did not keep — it resolved its own source set with no status rule at all, so a deleted profile still answered with 536 engagers and a deleted post with 83, the same rows /leads had been excluding. Keyword searches remain the deliberate exception on both endpoints: stopping a search keeps its leads, so its engagers stay readable too. Company fields: `companyName` (also `company`), `companyUrl` (website URL), `companyDomain` (website hostname), `companyLinkedinUrl` (LinkedIn company page), `companyDescription`, `companyIndustry`, `companyLocation` (headquarters), `companyEmployeeCount`, `companyStaffRange` and `companyEnrichedAt`. 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. Unknown facts are null. - DELETE /api/v1/profile/{username}:stop tracking a profile — the source is DEACTIVATED, not deleted, and NOTHING it captured is destroyed. Untracking stopped hard-deleting precisely because leads cascade off the source row: the leads you already paid for survive. ACCESS IS THE OTHER HALF AND ANSWERS DIFFERENTLY: those leads STOP BEING SERVED BY DEFAULT — GET /api/v1/leads and GET /api/v1/engagers exclude them and 404 on its profileId/username, as the dashboard does, and the source stops being pushable and its /webhook and /icp routes 404. THE READ IS RE-OPENABLE, THE ACTIONS ARE NOT: `?includeInactive=true` on /leads and /engagers returns the kept leads and makes that profileId/username resolve, while the push, /webhook and /icp stay 404 whatever you pass. That flag is an API opt-in only — the dashboard, its stat cards and its CSV export have no equivalent and still show nothing. Re-tracking serves them again everywhere; a 404 is out-of-scope, never proof of erasure. The source leaves GET /api/v1/sources (`?includeInactive=true` shows it again, `status: "inactive"`) and the daily sync stops. Reversible: re-tracking the same username revives that source rather than creating a second one. Immediate, and credits already spent are never refunded. ⭐ AND IT STOPS WORK THAT IS ALREADY RUNNING, which is the half that costs money and the half no surface used to state. A SWEEP ALREADY IN FLIGHT IS ABANDONED: the run asks "am I still tracked?" at every checkpoint it passes — before each provider call, before each post is harvested, before each chunk of lead rows — and stops at the first checkpoint after the delete. THAT IS "AT THE NEXT CHECKPOINT", NOT "INSTANTLY": a provider call already in flight finishes first and the check is coalesced behind a one-second window, so expect it within moments, not at the instant the delete returns; and if the status read itself FAILS the run carries on to the next checkpoint, because the control channel fails open on purpose. Leads it had already written are KEPT, and the run is finalised `completed` with `stoppedBy: "untracked"` on the /sync routes — NOT on `lastRun.stoppedBy`, which is a closed enum describing how a keyword SWEEP ended; see the three-spellings note below. QUEUED ENRICHMENT IS WRITTEN OFF AND NOT CHARGED, and this is where the money was: enrichment lands minutes to HOURS after capture and used to go on billing an untracked source until its backlog ran out (about 2,000 credits on one customer's test). 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 — scoped by that reason, so leads written off for any other reason are not resurrected with them. - DELETE /api/v1/company/{username}:stop tracking a company page — same contract as the profile delete above: DEACTIVATED, not deleted, nothing captured is destroyed, its leads RETAINED but STOP BEING SERVED BY DEFAULT (excluded from /leads and /engagers, 404 by profileId/username, not pushable, /webhook and /icp 404) — `?includeInactive=true` on /leads and /engagers reads those kept leads back and resolves that profileId/username, while the push, /webhook and /icp stay 404, and the dashboard has no such opt-in so its own view never changes — gone from GET /api/v1/sources unless `?includeInactive=true`, and revived by re-tracking the same username — which serves its leads again. Immediate, and credits already spent are never refunded. ⭐ AND IT STOPS WORK THAT IS ALREADY RUNNING, which is the half that costs money and the half no surface used to state. A SWEEP ALREADY IN FLIGHT IS ABANDONED: the run asks "am I still tracked?" at every checkpoint it passes — before each provider call, before each post is harvested, before each chunk of lead rows — and stops at the first checkpoint after the delete. THAT IS "AT THE NEXT CHECKPOINT", NOT "INSTANTLY": a provider call already in flight finishes first and the check is coalesced behind a one-second window, so expect it within moments, not at the instant the delete returns; and if the status read itself FAILS the run carries on to the next checkpoint, because the control channel fails open on purpose. Leads it had already written are KEPT, and the run is finalised `completed` with `stoppedBy: "untracked"` on the /sync routes — NOT on `lastRun.stoppedBy`, which is a closed enum describing how a keyword SWEEP ended; see the three-spellings note below. QUEUED ENRICHMENT IS WRITTEN OFF AND NOT CHARGED, and this is where the money was: enrichment lands minutes to HOURS after capture and used to go on billing an untracked source until its backlog ran out (about 2,000 credits on one customer's test). 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 — scoped by that reason, so leads written off for any other reason are not resurrected with them. - POST /api/v1/post/track:track a single LinkedIn post as a source, without tracking its author. Body: `{ "postUrl", "creditCapPerSync?": null }` — `creditCapPerSync` is the same per-sync limit the two enrich endpoints take, on the kind that has no `saveTrackedProfile` to condition it on, and re-tracking the same post with a new value changes it (which costs nothing: a post the team already has comes back unchanged rather than re-syncing; POST /api/v1/sources/{id}/sync syncs it now). A tracked post has no PATCH route for this, because its identifier is a URN rather than a username. — THREE forms are accepted, all verified against LinkedIn before the source is created: (1) `linkedin.com/feed/update/urn:li:activity:`, what you get by copying a post's link from your feed, also accepted with a `ugcPost` URN; (2) `linkedin.com/posts/--`, the share-menu permalink, which is RESOLVED against LinkedIn because a permalink slug's id can be a *share* id — a different number from the activity id; (3) a bare `urn:li:activity:` or `urn:li:ugcPost:`. A `urn:li:share:` is REJECTED in every form (bare or /feed/update/) with a 400 saying to paste the permalink instead — a share id cannot be mapped to its activity id without it, so the source would silently capture nothing. The `url` GET /api/v1/sources reports back is stored EXACTLY AS SENT when the post is FIRST tracked — send a feed URL, get a feed URL back; 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 and never creates a second one, so its stored url keeps the form used first even if you now send another; the status is 201 either way, and apart from a re-track of a post that has ALREADY SYNCED — which queues nothing and says so with `syncId: null` and `syncNotQueuedReason` — nothing in the body tells a duplicate from a create, and the 201 body echoes the postUrl YOU sent, which on a duplicate need not be the stored one. Re-tracking one you had UNTRACKED also tracks it again and queues a fresh capture, which charges. ⚠ LARGE POSTS STOP AT THE PROVIDER'S CEILING, ABOUT 1,100 PEOPLE (measured 29 September 2026: a public post declaring 3,490 reactions — the provider served ~1,100 reactors over 22 pages and then only empty pages, still declaring 3,490). No paging, retry or `creditCapPerSync` gets the rest; the sync reports it as `capture.stoppedBy: "provider_limit"` with `capture.coverage` giving declared and captured side by side, and a post under the ceiling still ends `exhausted`. Only the identifying URN is normalised. Its reactors and commenters are captured like any other source. PER SYNC, EVERY SYNC — it bounds EACH sync (the capture cap counts lead rows, while billing charges once per new person per source), not the first pull only and not a lifetime total, and it has NO ESTIMATE, NO CONFIRMATION GATE AND NO TEAM CEILING: nothing prices a sync before you set it, raising or lowering it never needs `confirmSpend`, and the team's `dailyCeiling` does not count a credit of it. ⚠️ NOT a keyword search's `creditCap`, which HAS all three — that is the daily bound on a RECURRING SWEEP, priced by POST /api/v1/keyword/estimate as `estimatedDailyMax`, gated by `confirmSpend` (409 `spend_confirmation_required`) and stopped by the team's `dailyCeiling`, which counts KEYWORD SPEND ONLY. - DELETE /api/v1/post/{urn}:stop tracking a post — capture stops and the post leaves GET /api/v1/sources (`?includeInactive=true` still lists it, with `status: "inactive"`). SOFT DELETE: the source is deactivated, not erased, because leads reference it. Its captured leads are RETAINED in your account but STOP BEING SERVED BY DEFAULT — GET /api/v1/leads and GET /api/v1/engagers exclude them and 404 on its id, exactly as the dashboard does. `?includeInactive=true` on those two endpoints reads them back and makes the id resolve; it is an API opt-in, the dashboard has none, and the post still cannot be pushed. Keyword searches are the one kind whose leads stay readable after untracking with no flag at all. Takes the post URN (`urn:li:activity:...`), not the permalink you tracked it with. ⭐ AND IT STOPS WORK THAT IS ALREADY RUNNING, which is the half that costs money and the half no surface used to state. A SWEEP ALREADY IN FLIGHT IS ABANDONED: the run asks "am I still tracked?" at every checkpoint it passes — before each provider call, before each post is harvested, before each chunk of lead rows — and stops at the first checkpoint after the delete. THAT IS "AT THE NEXT CHECKPOINT", NOT "INSTANTLY": a provider call already in flight finishes first and the check is coalesced behind a one-second window, so expect it within moments, not at the instant the delete returns; and if the status read itself FAILS the run carries on to the next checkpoint, because the control channel fails open on purpose. Leads it had already written are KEPT, and the run is finalised `completed` with `stoppedBy: "untracked"` on the /sync routes — NOT on `lastRun.stoppedBy`, which is a closed enum describing how a keyword SWEEP ended; see the three-spellings note below. QUEUED ENRICHMENT IS WRITTEN OFF AND NOT CHARGED, and this is where the money was: enrichment lands minutes to HOURS after capture and used to go on billing an untracked source until its backlog ran out (about 2,000 credits on one customer's test). 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 — scoped by that reason, so leads written off for any other reason are not resurrected with them. - POST /api/v1/keyword/track:track a keyword search — a saved LinkedIn search that captures leads from posts matching your terms, a source kind alongside profiles and company pages. Body: `{ "keywords": [1-10 terms], "name?", "sort?" (RELEVANCE|DATE_POSTED — stored and reported, but every search currently RUNS BY RELEVANCE whatever it holds), "datePosted?" (PAST_24_HOURS|PAST_WEEK|PAST_MONTH), "contentType?", "creditCap?", "captureMode?" (depth|breadth — depth, the default, lets one post spend the whole cap; breadth spreads it across posts), "maxEngagementsPerPost?" (breadth only — cap each post at this many engagements; omit for no ceiling; creditCap stays the hard limit), "mode?" (engagers|posts_only — default engagers, which captures PEOPLE; posts_only saves matching posts' text, captures no people and charges one credit per NEW kept post), "postsPerSync?" (1-60, posts_only's daily post allowance — REQUIRED with posts_only, a 400 without it, and a 400 in engagers mode), "captureEngagers?" (default true — the people who liked or commented), "captureReplies?" (default true — also the people who REPLY to comments, captured and charged as engagers; false skips them before any lead is written or charged; a non-boolean is a 400), "capturePostAuthors?" (default false — the person who WROTE each kept post, as an `engagementType: "Author"` lead; a company-page author is skipped and not charged) — captureEngagers and capturePostAuthors cannot both be false (400 `no_capture_target`) and either one sent with mode posts_only is a 400, because a posts_only search captures no people; on a resume each one you omit keeps the search's value, "runOnce?", "endAt?", "maxRuns?" (WHEN THIS SEARCH STOPS — see below) }`, ⭐ OR, INSTEAD OF keywords, "expression": a boolean search string — `hiring AND "sales ops" NOT recruiter OR fundraising`. Exactly one of the two; both is a 400 and neither is a 400. NOT binds tighter than AND, which binds tighter than OR, and THERE ARE NO PARENTHESES, so `a AND b OR c` is `(a AND b) OR c`. A PARENTHESIS IS A 400 explaining that precedence — with one exception: a search's own canonical `expression`, sent back UNCHANGED, compiles to the same search (so a search can be recreated from what it reports); any other bracket, and any term containing one (in `keywords` or in quotes), is refused, because the provider refuses such a term. Operators are UPPER CASE ONLY — a lower-case `and` is just a word to search for, which is what keeps an existing term like "sales and marketing" meaning what it always meant. Two terms with no operator between them is OR, i.e. exactly what a keywords list has always been, so `{"expression": "a OR b"}` and `{"keywords": ["a","b"]}` create the identical search. ⚠ ONLY OR IS SERVED BY THE SEARCH ITSELF. The provider takes ONE term per call and supports no boolean syntax, so each required term is one provider call per run — an AND/NOT expression costs exactly what the same terms as a keywords list cost — and AND and NOT are applied AFTERWARDS, by reading each post's own text (the post's body, never the author's name or headline). What changes is the YIELD, not the price: a narrow expression spends the same searches and keeps fewer posts. Two things bound that — the AND/NOT test runs BEFORE a post is kept, so nothing is captured from a post about to be thrown away; and credits are only ever charged at capture, so a discarded post costs no credits. A discarded post IS marked seen, so the next run does not pay to harvest it again, IS counted in lastRun.postsScanned and not in lastRun.postsKept, and IS LISTED by GET /api/v1/sources/{id}/kept-posts?include=swept with `outcome: "discarded"` and a `reason` naming the clause it failed — lastRun.discardedByExpression is how many. ⚠ A QUOTED PHRASE IS MATCHED AS A PHRASE in every expression shape, including on its own and in an OR-only branch: its words must be adjacent and in order, case-insensitively, with punctuation between them treated as a space. `"sales ops"` matches `our sales-ops lead` and not `sales, finance, ops, and tech`. LinkedIn still receives one provider search for that phrase and may return broad candidates, which the worker discards before anything is captured. Unquoted terms and legacy keyword-list chips retain broad provider matching. A malformed expression is a 400 AT SAVE TIME naming the exact token at fault — `NOT recruiter` on its own is refused (it has nothing to search for), so is an unbalanced quote and a dangling operator. 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; `expression` comes back in CANONICAL form, which is where the precedence becomes visible — read it back to confirm the operators parsed as you meant. ⭐ AND A SEARCH NEED NOT RECUR FOR EVER. It does by default — every search created before these controls does — and three fields bound it: `runOnce` (harvest once and stop), `endAt` (a UTC instant after which it stops scheduling; it must be in the FUTURE or the create is a 400) and `maxRuns` (stop after N completed runs, 1–3650). A run COUNTS when it reached the provider and ended ordinarily — 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 ran and captured nobody DOES. Read them back as `schedule` on GET /api/v1/sources; change them with PATCH /api/v1/keyword/{id}, where raising `maxRuns` above the runs already completed RESTARTS a search that stopped at it (that call clears the stop AND re-queues the search, so the next sweep is within a minute rather than never). A search whose schedule has ended keeps its leads, stays in your source list and reports `nextSyncAt: null`; nothing about `lastRun.stoppedBy` changes — a schedule ending is a fact about the SCHEDULE, not about the last run. plus optional AI post-filtering (`aiProvider` openai|grok|gemini|claude, `aiModel`, `aiPrompt` — these select a credential you have ALREADY STORED — saved once per team per provider in the dashboard's AI filtering panel on Keyword Engagement, NOT on any integrations page (there is none); the key itself is never accepted here and is returned by no endpoint). `aiModel` is OPTIONAL even with `aiProvider`: omit it and the provider default runs (openai gpt-6-luna, grok grok-4.3, gemini gemini-3.5-flash-lite, claude claude-haiku-4-5-20251001); a model sent with no provider is a 400. COST: each keyword is a SEPARATE provider call per run and the sweep repeats daily, so `creditCap` (enriching credits one run may spend) is THE control on what a search costs — the one limit you set; on a posts_only search the daily figure is min(postsPerSync, creditCap). ⚠ `postBudget` ("Posts per run") WAS RETIRED ON 30 SEPTEMBER 2026: a request that still sends it is ACCEPTED and the value is IGNORED — it never limits a run, is never stored and is never read back — and the reply names it in `ignoredFields: ["postBudget"]` so you can see it did nothing (the same on POST /api/v1/keyword/estimate and PATCH /api/v1/keyword/{id}). A run reads up to 2,000 posts, an internal scan ceiling you do not set. ⚠ WITH AN AI FILTER each scanned post is sent to YOUR OWN AI key, up to 2,000 posts in each run — 10 posts to a call, so about 200 calls, and one call per post when a batch reply cannot be read — and that is billed by your AI provider, not in Cornersight credits. 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 search's identity is its joined terms, so creating one whose keywords match a search the team already has — ACTIVE or one you DELETED — returns THAT search instead: 200 with `resumed: true` rather than 201, and `seenPosts` counts the posts it has already swept and will therefore SKIP. A genuine create is 201 with `resumed: false` and `seenPosts: 0`. Settings you send are applied to that search and ones you OMIT are left as they are, so a bare `{ "keywords" }` returns it unchanged rather than resetting its name, AI filter and caps to defaults; an explicit `null` clears a field, and `name`/`aiProvider` in the response are the search's current values, not an echo of the request. Resuming a DELETED search tracks it again and queues a sweep within seconds, which charges. Terms already held by a source of a different kind are a 409 `identifier_in_use`, naming it. WHEN AN EDIT TAKES EFFECT: settings changed here (or in the dashboard) apply from the NEXT run — a sweep already running keeps the settings it read when it started and is never re-bound mid-flight, while a change made before the sweep begins, including after its job is queued, does bind that run. Read both back on GET /api/v1/sources: `config` is what the next run will use, `lastRun.config` is what the last one did. WHAT IT COSTS IS IN THE REPLY: 201 and 200 alike carry `estimatedDailyMax` (the most ONE DAY of this search can cost in enriching credits, from the caps that will actually bind the next run) and `daysToExhaustAtCap` (whole days your remaining balance funds at that rate). QUOTE THE DAILY FIGURE TO THE PERSON WHO ASKED, before or as you create the search — the first sweep starts within SECONDS and charges, and this endpoint has no confirmation step. Both are OMITTED, never null and never 0, when there is no honest number; `daysToExhaustAtCap: 0` is the warning, not "free". ⚠ THE SPEND CONTRACT IS ROLLING OUT AND THE SHAPE OF A REFUSAL CHANGES ON A DATE. The dashboard has always gated this create: its step 3 "Search scope" pre-fills `creditCap` 100, `captureMode` depth, `datePosted` PAST_WEEK and will not save until Confirm is clicked under the cost banner. The API accepted a create with none of that, and a `creditCap` up to 2147483647 — measured 2026-09-09 on a team with 118,214 credits, creates with 100, 118,214 and 236,428 all returned 201 identically. THE NEW CONTRACT is version `2026-11-01`: the three scope fields REQUIRED (no server-side defaults) and `"confirmSpend": true` REQUIRED, on every create, on a RESUME (matching keywords re-cap the live search) and on any update RAISING the daily figure (`creditCap`, or `postsPerSync` on a posts_only search). TODAY IT IS IN ITS WARNING PHASE: an old-style create still returns 201/200, now carrying `deprecations: [{ code: "spend_confirmation_required", contractVersion, cutoverAt, add, message }]` where `add` is the exact JSON to merge — and the values in `add` are the ones the request ALREADY used (on a resume, the live search's own caps), so merging it changes nothing except that you have said it. `cutoverAt` is the instant the default flips, or `null` while it is unscheduled — it is never invented. ⚠ THE CUT-OVER IS SCHEDULED FOR 2026-11-01, AND THIS IS THE NOTICE OF IT. UNTIL 2026-11-01 an old-style create — one that omits the three scope fields and `confirmSpend` — 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` — 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 — 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 — so read the date from this documentation and treat `null` as "not configured here yet" rather than as "not happening". AFTER THE CUT-OVER the same request is `400` `scope_required` (with `field` naming the missing one and a message stating the exact JSON to add) or `409` `spend_confirmation_required` (carrying `creditCap`, `captureMode`, `datePosted`, `estimatedDailyMax`, `remainingBalance`, `daysToExhaustAtCap` — the dashboard's own numbers; show them to the person and re-send the identical request with `confirmSpend: true`, never a smaller cap). MOVE EARLY BY SENDING `"contractVersion": "2026-11-01"`, which applies the new rules to that request whatever the phase; `"2026-09-09"` declares the old contract and is accepted until the cut-over and not after; anything else is a `400` `invalid_contract_version` in both phases. AI filtering stays OPTIONAL throughout — `aiProvider`, `aiModel` and `aiPrompt` are untouched by this. ⚠ AND A RESUME THAT REWRITES SETTINGS IS NOW REFUSED FIRST. A resume applies every setting the call carries — that was documented for the caps, the name and the AI filter, and it was ALSO true of the seven targeting filters, silently: create with `fromPerson` A then with `fromPerson` B and the reply was 200 `resumed: true`, with no `previous`, no `changed` and nothing to notice, while GET /api/v1/sources then showed the one search holding B. A resume that would change any targeting filter, `sort`, `datePosted`, `contentType` or the AI filter is now `409` `settings_conflict` and CHANGES NOTHING, carrying `previous` (the search's FULL current settings), `settings` (what it would become) and `changed` (each differing field as `{ field, from, to }`). Show `changed` to the person, then re-send the identical request with `"confirmChanges": true` — 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, and each refusal names the one still missing. It was accepted for both until this release, which 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 `spend_confirmation_required` 409 CARRIES `changed` TOO when the same body would also rewrite a setting — that refusal is answered FIRST and asks you to re-send with `confirmSpend`, so it names the rewrite as well and one reading is enough to send both flags together. A bare resume that changes nothing, or one that only LOWERS a cap, is a plain 200; the caps are not in `changed` because a RAISE has its own `spend_confirmation_required`, and `name` is not either, because a rename costs nothing. POST /api/v1/keyword/estimate reports the same `previous` and `changed` and creates nothing — make that call first when you may be resuming. ⚠ THE SEVEN TARGETING FILTERS ARE VALIDATED FOR SHAPE. Six of them take LinkedIn IDS and never names or handles — see GET /api/v1/profile/{username}/urn above for the format, one worked example each, and the free lookup that turns a handle into the id `fromPerson`/`mentionsPerson` want. A bad value is a 400 `invalid_urn` naming the field, the value, the expected shape and an example; nothing is created. - POST /api/v1/keyword/estimate:price a keyword search WITHOUT creating it — the call to make FIRST. Takes THE SAME BODY as the create and answers 200 with `{ creditCap, captureMode, datePosted, mode, postsPerSync, captureEngagers, capturePostAuthors, resumed, previous?, remainingBalance?, estimatedDailyMax?, daysToExhaustAtCap?, filtering, ignoredFields? }`. CREATES NOTHING, RESUMES NOTHING, CHARGES NOTHING — no source row, no search row, no status change on a search you already have, no queued sweep, no credit. ⚠ WHY IT EXISTS: the 409 `spend_confirmation_required` already carries these numbers, but it makes "show me the cost" an ERROR PATH — a CLI has to print a refusal to answer a question, and an agent that treats non-2xx as failure never reads the body (MCP collapses a failed tool result to `{ statusCode, error }`, so the numbers are stripped). This answers 200, so they arrive. The 409 is unchanged and stays the enforcement. ⚠ THE CONTRACT'S TWO GATES DO NOT APPLY HERE: the three scope fields stay OPTIONAL even after the cut-over and `confirmSpend` is accepted and does nothing — this is the call you make in order to DECIDE the scope, so requiring it would be circular. Every other 400 the create gives, this gives, in the same order and with the same message: a body priced here is a body the create accepts. The caps are RESOLVED exactly as the create resolves them (sent wins, omitted inherits from the live search, else the default 100/depth/PAST_WEEK), which is why `estimatedDailyMax` here EQUALS the subsequent 201's. The capture fields are accepted with the create's defaults and refusals — `mode` (default engagers), `postsPerSync` (required with posts_only, a 400 in engagers mode), `captureEngagers` (default true), `captureReplies` (default true), `capturePostAuthors` (default false), both people flags refused with posts_only and both false a 400 `no_capture_target`. A posts_only body is priced min(postsPerSync, creditCap) — the figure the create's 201, its 409 and PATCH quote for it; an engagers body is priced creditCap whoever it captures, and `captureReplies` never moves the figure. A `postBudget` in the body is ignored and named in `ignoredFields`. `resumed: true` means these terms already name a search this team has (active or DELETED), so the create would RESUME it — and re-tracking a deleted one queues a charging sweep within seconds; `previous` then carries that search's CURRENT caps, under the same key and meaning the 409 uses. `filtering` carries `applied`, `aiKeyStoredFor` and — unlike the create's success body — `suggestion` when `applied` is false, because this IS the moment the offer belongs. estimatedDailyMax and daysToExhaustAtCap are OMITTED, never null and never 0, when there is no honest number; test for the KEY. The formula is written out on POST /api/v1/keyword/track. MCP `estimate_keyword_search`; CLI `keyword-estimate` (or `track-keyword --dry-run`). ⚠ `previous` IS THE WHOLE OF THE LIVE SEARCH'S SETTINGS, not just its three caps — the three cap keys are unchanged and every other setting a resume could overwrite is beside them. `changed` names what the create 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 POST /api/v1/keyword/track is a `409` `settings_conflict` unless it carries `confirmChanges` — `confirmSpend` authorises the charge only and does not acknowledge a rewrite. - DELETE /api/v1/keyword/{id}:stop tracking a keyword search — stops the daily sweep and the daily charge, and KEEPS every lead it already captured. SOFT DELETE: the source is deactivated, not erased, so it leaves GET /api/v1/sources (use `?includeInactive=true` to see it again) while its leads stay readable by its `id` and under `GET /api/v1/leads?sourceKind=keyword`. Takes the search's `id` from GET /api/v1/sources, NOT a username like the profile/company deletes. ⭐ AND IT STOPS WORK THAT IS ALREADY RUNNING, which is the half that costs money and the half no surface used to state. A SWEEP ALREADY IN FLIGHT IS ABANDONED: the run asks "am I still tracked?" at every checkpoint it passes — before each provider call, before each post is harvested, before each chunk of lead rows — and stops at the first checkpoint after the delete. THAT IS "AT THE NEXT CHECKPOINT", NOT "INSTANTLY": a provider call already in flight finishes first and the check is coalesced behind a one-second window, so expect it within moments, not at the instant the delete returns; and if the status read itself FAILS the run carries on to the next checkpoint, because the control channel fails open on purpose. Leads it had already written are KEPT, and the run is finalised `completed` with `stoppedBy: "untracked"` on the /sync routes — NOT on `lastRun.stoppedBy`, which is a closed enum describing how a keyword SWEEP ended; see the three-spellings note below. QUEUED ENRICHMENT IS WRITTEN OFF AND NOT CHARGED, and this is where the money was: enrichment lands minutes to HOURS after capture and used to go on billing an untracked source until its backlog ran out (about 2,000 credits on one customer's test). 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 — scoped by that reason, so leads written off for any other reason are not resurrected with them. Already untracked = 404 carrying `code: "keyword_delete_already_stopped"` ("That search is already stopped."), which is NOT the same 404 as a search that never existed — that one carries no code. Branch on `code`: for a keyword search the two are different facts, because a stopped search is still listed by `?includeInactive=true` and its leads are still served. A trial-created search = 403. - PATCH /api/v1/keyword/{id}:change a live keyword search's settings without re-creating it — the cap (`creditCap`), the scope (`captureMode`, `datePosted`, `sort`, `contentType`), who it captures (`mode`, `postsPerSync`, `captureEngagers`, `captureReplies`, `capturePostAuthors`, with the create's defaults and refusals: posts_only needs `postsPerSync` 1-60, `postsPerSync` in engagers mode is a 400, the two people flags are refused with posts_only and cannot both end up false — 400 `no_capture_target`), the AI filter (`aiProvider`, `aiModel`, `aiPrompt`), the seven targeting arrays and `name`. PARTIAL: only the fields present change, an omitted one is left exactly as it is, and an explicit `null` clears it — to null on a nullable column, to the create-time default on a NOT NULL one (`creditCap` 100, `captureMode` depth, `sort` DATE_POSTED, `datePosted` PAST_WEEK). Addressed by the SOURCE `id` from GET /api/v1/sources, never by the keyword text. ⚠ A CHANGE THAT RAISES THE DAILY FIGURE NEEDS `"confirmSpend": true` — the figure is `creditCap` on an engagers search and min(`postsPerSync`, `creditCap`) on a posts_only one, so raising `creditCap` alone on a posts_only search whose `postsPerSync` is below it needs NO confirmation; switching mode either way, and turning `captureEngagers` back on for an authors-only search, need it too — and is otherwise `409` `spend_confirmation_required`, carrying `previous` (what the search holds now), the resulting three scope values, `raised` (which setting pushed the figure up: `creditCap` or `postsPerSync`), `estimatedDailyMax`, `remainingBalance` and `daysToExhaustAtCap` — the same 409 shape POST /api/v1/keyword/track returns, with the before-and-after added, and an `error` message that names the search and the new daily cap. Nothing is changed; show the person the before, the after and the daily figure, then re-send the identical request with `confirmSpend: true`, never a smaller cap. LOWERING a cap, leaving it alone, or changing anything else needs no confirmation. THIS RULE IS NOT TIED TO THE CUT-OVER DATE: it applies today, here and on a resume alike, so `POST /api/v1/keyword/track` with matching keywords and a bigger `creditCap` is refused the same way. `contractVersion` is ACCEPTED HERE AND VALIDATED: a value outside the two the create accepts is a `400` `invalid_contract_version` before any column is written — a mistyped pin is refused, never dropped from an update that still returns 200 — while a legal pin changes nothing about this endpoint's answer, because the cap-raise gate above is already the same under either version and in either phase. A TARGETING FILTER IS VALIDATED FOR SHAPE HERE TOO, by the same code path as the create: a handle or a name in any of the six URN filters is a 400 `invalid_urn` naming the field and the value, and nothing is written. `keywords` CANNOT be changed here — sending it is a 400, not a silent drop, because a search's terms are its identity and its seen-set is keyed to the search rather than to a term — AND NEITHER CAN `expression`, for the same reason and one more: the seen-set is keyed to the SEARCH, so a new expression would inherit the posts the old one rejected and silently sweep a smaller universe than it appears to. Delete and re-create to change either. ⭐ THE SCHEDULE *IS* EDITABLE HERE: `runOnce`, `endAt` and `maxRuns` are partial like every other field, `null` clears a bound, and `endAt` must still be in the future. RAISING `maxRuns` ABOVE `schedule.runsCompleted` RESTARTS A STOPPED SEARCH, and this endpoint does BOTH halves of that — it clears `schedule.stoppedAt`/`stoppedReason` AND makes the source due again, so the next sweep runs within a minute instead of never (the worker parks a stopped search far in the future, where the due-query can never reach it). An edit that leaves the search stopped — a budget still at or below the runs it has had, an end date still in the past — deliberately does NOT clear the reason it is stopped. TAKES EFFECT FROM THE NEXT RUN: a sweep reads its settings once, when it starts. `postBudget` is retired here as everywhere: a body that sends it is still a 200, the value is ignored, and `ignoredFields: ["postBudget"]` says so — a body carrying nothing else changes nothing. The 200 re-selects the row, so every field you left alone comes back at its real value rather than as the null your body implied. MCP `update_keyword`; CLI `keyword-update`. - POST /api/v1/discover: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. Body: `{ "keywords": ["..."] (1-10, any may match), "minEngagement": = 0>, "maxInfluencers": <1-500; up to 100 on a free trial>, "countries?": ["..."] (up to 20), "confirmSpend": true }`. Without confirmSpend: 409 `spend_confirmation_required` + `estimatedCredits`, nothing created. 201 → the run `{ id, keywords, expression, minEngagement, maxInfluencers, countries, datePosted: "PAST_MONTH", state: "running", candidates, qualified, found, countryChecked, countryMatched, createdAt, finishedAt, stoppedBy, reason }`. 1 credit per influencer added, once. See "Discover Influencers" above. - GET /api/v1/discover:the team's Discover runs, newest first (50 most recent, deleted excluded) → `{ runs: [], total }`. `candidates`/`qualified`/`countryChecked`/`countryMatched` null until the run finishes; `qualified > found` = stopped at maxInfluencers. Free. - GET /api/v1/discover/{id}:one run and its influencers, highest average first → `{ run, influencers: [{ name, linkedinUrl, avatarUrl, jobTitle, company, country, highestEngagement, avgEngagement, postCount, totalEngagement, topPostUrl, topPostText }] }`. jobTitle/company/country null until enrichment fills them. 404 for a deleted run or a keyword search's id. Free. - DELETE /api/v1/discover/{id}:soft-delete a Discover run → `{ ok: true, id, deleted: true }`. The run and its influencers leave the lists; its Author leads stay in your leads; no refund. 404 if already deleted or not a Discover run; 403 for a trial run. - POST /api/v1/profile/posts:fetch a person's recent posts. Body: `{ "username", "limit?": 1-15, "paginationToken?" }` (`limit` sizes the page, default 15; outside 1-15 is a 400). Async. An untracked profile is a 404 `Profile not tracked`, like one never tracked. - POST /api/v1/company/posts:fetch a company page's recent posts. Body: `{ "username", "paginationToken?" }`. Async. - Post response `contentType` on all four post reads — POST /api/v1/profile/posts and POST /api/v1/company/posts (in the completed job's `result.data[]`), plus GET /api/v1/profile/{username}/posts and GET /api/v1/company/{username}/posts (in `posts[]`) — is always present. Its values are VIDEO, IMAGE, JOB, LIVE_VIDEO, DOCUMENT, or COLLABORATIVE_ARTICLE: exactly the keyword search `contentType` request filter vocabulary. It is `null` when the provider supplies no explicit matching type or identifiable image, video, or document; ordinary text and articles must not be guessed into that vocabulary. - POST /api/v1/post/reactions:fetch reactors of a post. Body: `{ "postUrn", "page?", "source?": "primary"|"fallback" }` (`source`: echo the previous page's, see below). WHICH POSTS: `postUrn` must be a post THIS TEAM HOLDS — one of the 15 most recent posts of a tracked person, company page or tracked post, OR a post one of its keyword searches harvested (any `urn` /kept-posts returns with a non-null `url`, a stopped search's included) or one of its posts-only watches saw (/sources/{id}/posts). ⚠ COST: the call itself charges nothing. On a tracked post the engagers are also saved as that source's leads (the people its own sync captures; a person new to the source is enriched and billed 1 credit afterwards, like any of its leads). A keyword-search or posts-only post is only READ — the same rows come back and none is saved — so it charges nothing then or later and touches neither the search's `creditCap` nor the `dailyCeiling`. 404 `Post not found` = the team holds no such post, and a post only ANOTHER team holds answers exactly the same; 404 starting `Post not tracked` = held only as an older post of a tracked profile/page (outside its newest 15) or under an untracked source (a person, company page or posts-only watch) — track it with POST /api/v1/post/track (which captures and charges like any tracked source) and retry once its first sync has finished. Immediate — the returned jobId is already completed; read the result via one job-status call. 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 (~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) — never cache page 0's value and compare later pages to 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, the fallback provider served 22 pages of ~50 (1,093 unique identified reactors) and then an empty page, with `total` still 3,490 on every page — the last page says `hasMore: false` and `exhausted: false`, and neither provider serves the rest on any retry. A tracked post's sync reports the same wall as `capture.stoppedBy: "provider_limit"` with `capture.coverage`, by the same one-page rule. `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: 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 (no provider count and no stored count). `hasMore` still falls back to page-fullness on a page the provider gave no count for, the same 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. SEND IT AS `source` IN THE NEXT REQUEST (`{ postUrn, page, source }`), unchanged, for every page after the first of the same sweep. It is the ONLY paging state besides `page`. When the primary has nothing for a post, a fallback answers `page: 0`; a request for `page: 1` 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 whole of the FALLBACK-SERVED SHORTFALL, measured 2026-09-08 at 50 of 278 declared (18% reachable) and 49 of 574 (9%), and on the same post a day earlier at 50 of 104 — 52% of the declared reactors unreachable, because the gap is capped by ONE PAGE whatever the post declares and so grows with the post rather than staying a fixed share. It is stable across retries, so re-running the sweep does not help; carrying `source` does, and the same sweep then pages to exhaustion. Omitting it 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. THE SIGNATURE OF HAVING OMITTED IT: rows on page 0 with `source: "fallback"`, an empty page 1, `exhausted: false`. ⚠️ `hasMore: false` DOES NOT MEAN "YOU HAVE THEM ALL" — READ `exhausted`. `hasMore` is still the termination signal and the only field to branch on for whether to make another request. `exhausted` answers the different question of whether the set is complete: `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 — 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, because that is the unit the provider serves in. Mid-sweep, while `hasMore` is true, `exhausted` is `false` and means only "not yet". `total` is never `0` past the end: the provider sends 0 there and Cornersight reports the post's own stored reaction count instead, so a `0` means the post really has no reactions and only ever arrives on page 0. PAGE SIZE IS FIXED AT 50 — `page` is the only paging control, there is no `size` parameter, and a `size` in the body is ignored rather than rejected; the exactly-full last page therefore only occurs when a post's count is an exact multiple of 50 (14 of 1,000 measured posts). ⚠️ /post/comments has BOTH fields too and they mean DIFFERENT things there — don't carry the rule across. ⚠️ IDENTITY MAY BE NULL: some engagements arrive with no identifiable person (~1 in 116 reactions, ~1 in 300 comments). Those rows come back with every identity field present and `null` — never omitted — so every row has the same shape. They are COUNTED by `total`/`hasMore`, so don't read `total` as a count of contactable people. ⚠ A NULL `username` IS NOT THAT TEST. A person with no PUBLIC vanity handle also comes back with `username: null` — LinkedIn gives them no slug, so their profile URL is built from their member id — and they ARE usable: since 2026-09-08 capture keys them on `entityUrn` and they become leads with `linkedinUsername: null` and `linkedinUrn` set. On some posts that is EVERY reactor, so filtering them out discards the majority of a post's engagers. THE ROWS CAPTURE ACTUALLY SKIPS are those with a null `username` AND a null `entityUrn` — no identity at all — plus organisation pages, whose `entityUrn` is a `urn:li:organization:…` rather than the `ACoAA…` member form. So filter on `entityUrn`, not on `username`. - POST /api/v1/post/comments:fetch comments on ANY post this team holds (personal or company). Body: `{ "postUrn", "page?", "size?": 1–50, "includeReplies?": true }`. WHICH POSTS, COST and the two 404s exactly as /post/reactions: a post this team holds — a tracked post (newest 15 of its source) or one a keyword search harvested or a posts-only watch saw; the call charges nothing, and a keyword-search or posts-only post is only READ (no lead saved), so it charges nothing later either. Immediate — the returned jobId is already completed; read the result via one job-status call. ⚠️ GRAIN: `total` is NOT a row count. It counts TOP-LEVEL comments only, while `data` ALSO carries their replies, each flagged `isReply: true` with a `parentCommentUrn` — so `data.length` can be LARGER than `total` on the same page. NEVER page against `total` here; it would stop you on the first page. `hasMore` IS provided but is COARSER than reactions': it is the empty-page rule (true whenever `data` is non-empty), so the last request of a sweep still returns an empty page. On /post/reactions `total` is a post-level count and `hasMore` a strong hint; comments' `total` counts top-level comments only and its `hasMore` is page-fullness — same field names, different meanings, neither exact. ⚠️ IDENTITY MAY BE NULL: some engagements arrive with no identifiable person (~1 in 116 reactions, ~1 in 300 comments). Those rows come back with every identity field present and `null` — never omitted — so every row has the same shape. They are COUNTED by `total`/`hasMore`, so don't read `total` as a count of contactable people. ⚠ A NULL `username` IS NOT THAT TEST. A person with no PUBLIC vanity handle also comes back with `username: null` — LinkedIn gives them no slug, so their profile URL is built from their member id — and they ARE usable: since 2026-09-08 capture keys them on `entityUrn` and they become leads with `linkedinUsername: null` and `linkedinUrn` set. On some posts that is EVERY reactor, so filtering them out discards the majority of a post's engagers. THE ROWS CAPTURE ACTUALLY SKIPS are those with a null `username` AND a null `entityUrn` — no identity at all — plus organisation pages, whose `entityUrn` is a `urn:li:organization:…` rather than the `ACoAA…` member form. So filter on `entityUrn`, not on `username`. COMMENT TIME: every row carries `postedAt` (ISO 8601) and `postedAtTimestamp` (epoch ms), the post rows' names and types, 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 does; it always wins), otherwise the time the comment's own ID encodes — in `urn:li:comment:(activity:,)`, `commentId` shifted right by 22 bits is its creation time in epoch ms (matched every published example checked to within 2 ms). That is how the primary provider's rows, which have no time field, carry one. A decoded time is used only when plausible (after 2003-05-01, not in the future, not before the post), else `null`. Neither is ever filled from the post's time. On a lead captured from a comment the same time is `commentPostedAt`; `postPostedAt` is the POST's time. ROW SHAPE: every row has the same keys whichever provider served it (openapi.json PostComment): `id` (the comment's URN — stable, dedupe on it), `url` (opens the comment), `text`, `postedAt`, `postedAtTimestamp`, `isReply`, `parentCommentUrn`, `isEdited`, `isPinned`, `totalReactions`, `totalComments` (replies), `reactionType`, `author` { `type` (person|company|null), `username`, `profileUrl`, `url`, `linkedinUrl`, `name`, `firstName`, `lastName`, `headline`, `profilePicture`, `entityUrn` }; unknown values are `null`. A company commenting as itself has `author.type` `company`, no `username` and a linkedin.com/company/ URL, and is never a lead. `comment`/`commenter` are deprecated aliases of `text`/`author`. The result is `{ data, total, hasMore, source }`; `source` (primary|fallback) reports which provider served the page — a report only, comment requests take no `source`. 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% top-level comments; a given post can differ widely, so filter on `isReply` rather than assuming a ratio. - POST /api/v1/post/company-comments:fetch comments on ANY post this team holds (company or personal). Body: `{ "postUrn", "page?", "size?": 1–50, "includeReplies?": true }`. WHICH POSTS, COST and the two 404s exactly as /post/reactions: a post this team holds — a tracked post (newest 15 of its source) or one a keyword search harvested or a posts-only watch saw; the call charges nothing, and a keyword-search or posts-only post is only READ (no lead saved), so it charges nothing later either. Immediate — the returned jobId is already completed; read the result via one job-status call. Same GRAIN and paging caveats as /post/comments: `size` controls top-level comments per page (1–50, default 50); `includeReplies` defaults true and false returns top-level rows only with `isReply:false`. `total` counts top-level comments; with replies included, `data` can exceed it, and `hasMore` is the coarse empty-page rule. ⚠️ CHOOSE EITHER: /post/comments and /post/company-comments accept ANY post URN regardless of author type and neither checks it. Identical primary path — same provider call, same page size, same ordering — differing only in which upstream endpoint serves a fallback when the primary call fails. An HTTP 500 always diverts; with ENGAGER_TRANSIENT_FALLBACK_ENABLED=true (off by default, set on the deployment) a 429/502/503/504 that outlives its retries and a connection-level or timeout failure divert too, and with the flag off those surface as errors instead. A fallback result can carry fewer replies either way. ⚠️ IDENTITY MAY BE NULL: some engagements arrive with no identifiable person (~1 in 116 reactions, ~1 in 300 comments). Those rows come back with every identity field present and `null` — never omitted — so every row has the same shape. They are COUNTED by `total`/`hasMore`, so don't read `total` as a count of contactable people. ⚠ A NULL `username` IS NOT THAT TEST. A person with no PUBLIC vanity handle also comes back with `username: null` — LinkedIn gives them no slug, so their profile URL is built from their member id — and they ARE usable: since 2026-09-08 capture keys them on `entityUrn` and they become leads with `linkedinUsername: null` and `linkedinUrn` set. On some posts that is EVERY reactor, so filtering them out discards the majority of a post's engagers. THE ROWS CAPTURE ACTUALLY SKIPS are those with a null `username` AND a null `entityUrn` — no identity at all — plus organisation pages, whose `entityUrn` is a `urn:li:organization:…` rather than the `ACoAA…` member form. So filter on `entityUrn`, not on `username`. COMMENT TIME: every row carries `postedAt` (ISO 8601) and `postedAtTimestamp` (epoch ms), the post rows' names and types, 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 does; it always wins), otherwise the time the comment's own ID encodes — in `urn:li:comment:(activity:,)`, `commentId` shifted right by 22 bits is its creation time in epoch ms (matched every published example checked to within 2 ms). That is how the primary provider's rows, which have no time field, carry one. A decoded time is used only when plausible (after 2003-05-01, not in the future, not before the post), else `null`. Neither is ever filled from the post's time. On a lead captured from a comment the same time is `commentPostedAt`; `postPostedAt` is the POST's time. ROW SHAPE: every row has the same keys whichever provider served it (openapi.json PostComment): `id` (the comment's URN — stable, dedupe on it), `url` (opens the comment), `text`, `postedAt`, `postedAtTimestamp`, `isReply`, `parentCommentUrn`, `isEdited`, `isPinned`, `totalReactions`, `totalComments` (replies), `reactionType`, `author` { `type` (person|company|null), `username`, `profileUrl`, `url`, `linkedinUrl`, `name`, `firstName`, `lastName`, `headline`, `profilePicture`, `entityUrn` }; unknown values are `null`. A company commenting as itself has `author.type` `company`, no `username` and a linkedin.com/company/ URL, and is never a lead. `comment`/`commenter` are deprecated aliases of `text`/`author`. The result is `{ data, total, hasMore, source }`; `source` (primary|fallback) reports which provider served the page — a report only, comment requests take no `source`. 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% top-level comments; a given post can differ widely, so filter on `isReply` rather than assuming a ratio. - GET /api/v1/profile/{username}/sync:sync progress for a tracked profile (stage + counts, capture and enrichment). Poll until isFinal, which covers enrichment too. - GET /api/v1/company/{username}/sync:sync progress for a tracked company page. - ⚠️ `stoppedBy` IS SPELLED IN THREE PLACES WITH TWO DISJOINT VALUE SETS. On GET /api/v1/sources, `lastRun.stoppedBy` is how a KEYWORD SWEEP ended — `credits`/`post_limit`/`exhausted`/`error`/`ai_error`/`team_cap`/`lead_cap` (`budget` only on runs from before "Posts per run" was retired on 30 September 2026), null before the first run, and always null for the other three kinds. On every `/sync` route (`/profile/{username}/sync`, `/company/{username}/sync`, `/keyword/{id}/sync`, `/sources/{id}/sync`), top-level `stoppedBy` names HOW THIS CAPTURE RUN ENDED — the same ending as `capture.stoppedBy` on that response: `credit_cap` (the source’s own `creditCapPerSync` was reached; an ORDINARY ending, `state` stays `completed` and `errorCode` is null — spelled `credits` inside `capture`), `exhausted`, `capture_empty`, `provider_limit`, `untracked` (the source was untracked mid-run and the sweep was abandoned), or null when there is no ending to name (no finished run, a FAILED run, a keyword sweep). A KEYWORD sweep is always null there: its ending is recorded on the other field. Do not map one onto the other. THE THIRD PLACE IS THE WEBHOOK PAYLOAD, and it belongs to the FIRST list: `stoppedBy` on `sync.completed`/`sync.failed` is how a KEYWORD SWEEP ended (`credits`/`post_limit`/`exhausted`/`team_cap`/`lead_cap`, or `error`/`ai_error` on a failure), NOT the `/sync` route's `credit_cap`/`untracked`. So the same ending is spelled `credits` in the payload and `credit_cap` on the route, and a subscriber who learned one will not find it in the other. RENAMING ONE IS A BREAKING CHANGE and has not been made: treat them as two vocabularies and read which surface you are on. PER SYNC, EVERY SYNC — it bounds EACH sync (the capture cap counts lead rows, while billing charges once per new person per source), not the first pull only and not a lifetime total, and it has NO ESTIMATE, NO CONFIRMATION GATE AND NO TEAM CEILING: nothing prices a sync before you set it, raising or lowering it never needs `confirmSpend`, and the team's `dailyCeiling` does not count a credit of it. ⚠️ NOT a keyword search's `creditCap`, which HAS all three — that is the daily bound on a RECURRING SWEEP, priced by POST /api/v1/keyword/estimate as `estimatedDailyMax`, gated by `confirmSpend` (409 `spend_confirmation_required`) and stopped by the team's `dailyCeiling`, which counts KEYWORD SPEND ONLY. - PATCH /api/v1/profile/{username}:CHANGE A TRACKED PERSON’S PER-SYNC CREDIT LIMIT WITHOUT RE-TRACKING IT. Body: at least one of `{ "creditCapPerSync": <1-2147483647> | null, "mode": "engagers"|"posts_only", "captureReplies": boolean, "enrichLeads": boolean }` (`mode: "posts_only"` needs `"postsPerSync": 1-60`; switching a posts-only source back to `engagers` is refused 409 `spend_confirmation_required` and changes nothing until re-sent with `"confirmSpend": true`, while switching to posts-only never needs it) — a body naming none of them changes nothing and is a 400, because it cannot honestly be a 200; `null` REMOVES the limit, and `enrichLeads: false` keeps the source's leads RAW (captured and charged as before, never enriched, read with GET /api/v1/leads/raw), which needs no `confirmSpend` and applies to leads captured after it. THIS IS THE EDIT, and it is the reason the route exists: `creditCapPerSync` otherwise reaches a person only through POST /api/v1/enrich/profile, which also runs an enrichment job — and on a source that has already synced queues NO sync: the new limit simply applies from the next scheduled run. This writes the column and nothing else: no sync queued, no lead enriched, nothing charged. Addressed by the SAME username DELETE /api/v1/profile/{username} takes. Returns `{ sourceId, type, username, creditCapPerSync, previous: { creditCapPerSync } }`. ⚠️ IT IS NOT A KEYWORD SEARCH’S CAP: that is `creditCap`, it lives on `config.creditCap`, and PATCH /api/v1/keyword/{id} owns it — the two shared a name until this release and no longer do. Sending the old `creditCap` here is a 400 `renamed_field`; any other field is a 400 `unknown_field`. ⚠️ AN EDIT BINDS FROM THE NEXT SYNC, never the one already running: a run reads the limit when it starts and holds it. A TRACKED POST has no route here (its identifier is a URN, not a username) — re-send POST /api/v1/post/track, which costs nothing for a post already tracked. MCP `update_profile`; CLI `profile-update`. PER SYNC, EVERY SYNC — it bounds EACH sync (the capture cap counts lead rows, while billing charges once per new person per source), not the first pull only and not a lifetime total, and it has NO ESTIMATE, NO CONFIRMATION GATE AND NO TEAM CEILING: nothing prices a sync before you set it, raising or lowering it never needs `confirmSpend`, and the team's `dailyCeiling` does not count a credit of it. ⚠️ NOT a keyword search's `creditCap`, which HAS all three — that is the daily bound on a RECURRING SWEEP, priced by POST /api/v1/keyword/estimate as `estimatedDailyMax`, gated by `confirmSpend` (409 `spend_confirmation_required`) and stopped by the team's `dailyCeiling`, which counts KEYWORD SPEND ONLY. - PATCH /api/v1/company/{username}:CHANGE A TRACKED COMPANY PAGE’S PER-SYNC CREDIT LIMIT WITHOUT RE-TRACKING IT. Body: at least one of `{ "creditCapPerSync": <1-2147483647> | null, "mode": "engagers"|"posts_only", "captureReplies": boolean, "enrichLeads": boolean }` (`mode: "posts_only"` needs `"postsPerSync": 1-60`; switching a posts-only source back to `engagers` is refused 409 `spend_confirmation_required` and changes nothing until re-sent with `"confirmSpend": true`, while switching to posts-only never needs it) — a body naming none of them changes nothing and is a 400, because it cannot honestly be a 200; `null` REMOVES the limit, and `enrichLeads: false` keeps the source's leads RAW (captured and charged as before, never enriched, read with GET /api/v1/leads/raw), which needs no `confirmSpend` and applies to leads captured after it. THIS IS THE EDIT, and it is the reason the route exists: `creditCapPerSync` otherwise reaches a company page only through POST /api/v1/enrich/company, which also runs an enrichment job — and on a source that has already synced queues NO sync: the new limit simply applies from the next scheduled run. This writes the column and nothing else: no sync queued, no lead enriched, nothing charged. Addressed by the SAME username DELETE /api/v1/company/{username} takes. Returns `{ sourceId, type, username, creditCapPerSync, previous: { creditCapPerSync } }`. ⚠️ IT IS NOT A KEYWORD SEARCH’S CAP: that is `creditCap`, it lives on `config.creditCap`, and PATCH /api/v1/keyword/{id} owns it — the two shared a name until this release and no longer do. Sending the old `creditCap` here is a 400 `renamed_field`; any other field is a 400 `unknown_field`. ⚠️ AN EDIT BINDS FROM THE NEXT SYNC, never the one already running: a run reads the limit when it starts and holds it. A TRACKED POST has no route here (its identifier is a URN, not a username) — re-send POST /api/v1/post/track, which costs nothing for a post already tracked. MCP `update_company`; CLI `company-update`. PER SYNC, EVERY SYNC — it bounds EACH sync (the capture cap counts lead rows, while billing charges once per new person per source), not the first pull only and not a lifetime total, and it has NO ESTIMATE, NO CONFIRMATION GATE AND NO TEAM CEILING: nothing prices a sync before you set it, raising or lowering it never needs `confirmSpend`, and the team's `dailyCeiling` does not count a credit of it. ⚠️ NOT a keyword search's `creditCap`, which HAS all three — that is the daily bound on a RECURRING SWEEP, priced by POST /api/v1/keyword/estimate as `estimatedDailyMax`, gated by `confirmSpend` (409 `spend_confirmation_required`) and stopped by the team's `dailyCeiling`, which counts KEYWORD SPEND ONLY. - GET /api/v1/profile/{username}/icp:the ICP criteria (filter rules + match mode) behind a tracked person's `isIcp` flag and icpOnly webhook delivery → `{ username, profileType, icp: { matchMode, rules: [{ column, operator, value }] } }`; `rules: []` means no filter. - PUT /api/v1/profile/{username}/icp:set a tracked person's ICP criteria. Partial: `{ "rules?": [{ "column", "operator", "value" }], "matchMode?": "all"|"any", "dryRun?": false }`; `rules: []` clears the filter, and existing leads are re-scored immediately. `dryRun: true` changes nothing and answers with `counts: { matching, notMatching }` over the source's existing leads. - GET /api/v1/company/{username}/icp:the same read for a tracked company page. - PUT /api/v1/company/{username}/icp:the same write for a tracked company page, `dryRun` included. - GET /api/v1/keyword/{id}/icp:the same read for a KEYWORD SEARCH, addressed by its source id from GET /api/v1/sources (never by its keyword text). - PUT /api/v1/keyword/{id}/icp:the same write for a keyword search, by source id, `dryRun` included. - GET /api/v1/sources/{id}/sync:SYNC STATUS FOR ANY SOURCE KIND, addressed by the source `id` from GET /api/v1/sources. The only way to follow a TRACKED POST's lifecycle: POST /api/v1/post/track returns a `syncId` and, until this route existed, nothing took it. Body is `{ sourceId, type, username }` (the same `id`, `type` and `username` GET /api/v1/sources reports) plus the SAME lifecycle object the username-keyed /sync routes return — state, description, isFinal, queuedAt/startedAt/updatedAt/completedAt, `capture` (the capture half alone), `enrichment` (pending/completed/failed/skipped), `progress`, lastSyncedAt, nextSyncAt, leadsReady, error, errorCode. isFinal covers ENRICHMENT as well as capture, so a source reads `enriching` with isFinal false while its leads are still being enriched and billed. Works for a person, a company page, a tracked post and a keyword search alike; an UNTRACKED person, company or post is a 404 (its leads are out of scope too), while a STOPPED keyword search stays readable. - POST /api/v1/sources/{id}/sync:SYNC A TRACKED PERSON, COMPANY PAGE OR POST NOW, by source id, instead of waiting for its next scheduled sync — re-tracking does not re-sync, this does. Charged like any sync of that source (one credit per NEW person, repeats free, bounded by its `creditCapPerSync`). No body. 202 `{ sourceId, type, username, queued: true, syncId, status: "pending" }`; a sync already queued, running or paused for credits is returned instead as 200 with `queued: false`, its `syncId` and a `message`, and nothing is stacked. Follow it with GET /api/v1/sources/{id}/sync. The next scheduled sync moves to at least 24 hours out. A KEYWORD SEARCH is a 400 `keyword_search_not_syncable` (it runs on its own schedule and budget; there is no run-now route for one); an untracked source is a 404. - GET /api/v1/sources/{id}/webhook:webhook configuration for ANY source kind, by source id, as `{ sourceId, type, username, webhook: { webhookUrl, icpOnly, autoSend, syncEvents } }`. Same column and same delivery for every kind — a tracked post's and a keyword search's `lead.detected` have always fired; only reading and setting them outside the dashboard is new. - PUT /api/v1/sources/{id}/webhook:configure ANY source's webhook by source id. PARTIAL update: only the fields present change, and a body with none of them is a 400 rather than a no-op. EXPLICIT `false` IS A VALUE (`autoSend: false` = deliver only on an explicit push). `webhookUrl: ""` or `null` CLEARS delivery, and turns `syncEvents` off with it because there is nowhere left to deliver; asking for `syncEvents: true` with no URL is a 400. The URL passes the same guard delivery applies, so a savable URL is always a fireable one. Saving a URL does NOT deliver history — the push routes do that. FOR A TRACKED POST, `webhookUrl`, `icpOnly` and `autoSend` apply as for any source (`autoSend: false` holds its leads for `POST /api/v1/sources/{id}/push`, which also pushes its history), and `syncEvents: true` is a 400 `not_supported_for_post` (a post sends no sync events). - GET /api/v1/sources/{id}/icp:ICP criteria for ANY source kind, by source id, as `{ sourceId, type, username, icp: { matchMode, rules } }`. `rules: []` means no filter, which is not the same as a filter that matches nothing. - PUT /api/v1/sources/{id}/icp:set ANY source's ICP criteria by source id. Partial: `rules` and/or `matchMode`; `rules: []` clears the filter and makes every lead ICP again. 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` asks what a rule set WOULD select without applying it: 200 with `dryRun: true`, `counts: { matching, notMatching }` over every lead the source has captured, a `sample` of up to ten matching leads (`id`, `name`) and the `icp` (`matchMode`, `rules`) that was evaluated — nothing written, nothing fired, and the effective config is this body over what is stored, so `{"matchMode":"any","dryRun":true}` scores the STORED rules under the new mode. - ⚠️ `dryRun` IS ACCEPTED ON EIGHT ROUTES AND REFUSED EVERYWHERE ELSE: the four pushes (POST /api/v1/{profile|company}/{username}/push, /api/v1/keyword/{id}/push and /api/v1/sources/{id}/push) and the four ICP writes (PUT /api/v1/sources/{id}/icp, /api/v1/profile/{username}/icp, /api/v1/company/{username}/icp, /api/v1/keyword/{id}/icp). On any other body it is an UNKNOWN FIELD — a 400 with `code: "unknown_field"` naming it — because these settings bodies used to drop what they did not understand, and a dropped `dryRun` is a 200 that already changed the source. The same refusal now guards the webhook PUTs and PATCH /api/v1/keyword/{id}: send only the fields those endpoints document. - GET /api/v1/jobs/{jobId}/status:poll a job. Returns `{ status, result? }`; status ∈ pending|running|completed|failed. A comments job's `result` is `{ data: PostComment[], total, hasMore, source }` (see ROW SHAPE above). - GET /api/v1/credits:enriching credit balance AND the day's keyword spend. Returns `{ balance, used, limit, remaining, plan, resetAt, nextReset, spentToday, dailyCeiling, dailyCeilingMode }`. The first seven describe the BILLING PERIOD; the last three describe TODAY (UTC). ⚠️ `spentToday` IS KEYWORD SPEND AND `used` IS ENRICHMENT CHARGES, and neither is a subset of the other at the moment you read them: a keyword sweep spends when it writes a lead row, while `used` counts the enrichment charge for that lead, which lands minutes to hours later — they are the same money measured at two moments and they converge. `spentToday` is the number `dailyCeiling` is enforced against. `dailyCeiling` is `null` for NO CEILING, which is an ANSWER rather than a missing value — `dailyCeilingMode` says which: `none` (an admin turned it off), `default` (nobody chose: no ceiling; Cornersight never sets one), `custom` (an admin set it, the only mode with a number). Off on a free trial whatever is stored. A run that crosses it STOPS AT it and reports `lastRun.stoppedBy: "team_cap"`; the day's remaining searches are skipped and run again tomorrow. This is the endpoint to read BEFORE telling anyone a search is safe to create. - GET /api/v1/credits/usage:dated + by-source breakdown of enriching credits charged. Query: `from?`, `to?` (ISO 8601; defaults to the current billing period). Returns `totalCharged`, `bySource` (charge TYPE), `byDate` (UTC day) and `bySourceId` (the tracked SOURCE, with `username`, `type` and `status`). `bySourceId` always sums to `totalCharged`, including charges for untracked sources (`status: "inactive"`) whose leads GET /api/v1/leads no longer returns, and an explicit `sourceId: null` bucket for charges whose source row is gone. - POST /api/v1/profile/{username}/push:PUSH ALREADY-CAPTURED LEADS to this source's webhook — historical backfill, ICP-only or all, and the explicit retry for a failed delivery. Body: `{ "scope?": "all"|"icp", "since?", "until?" (ISO 8601), "idempotencyKey?", "dryRun?" }`, every field optional. HISTORICAL IS DEFINED BY THE LEAD, NOT THE WEBHOOK: selection is `detectedAt` in the half-open window `[since, until)`, and an omitted `until` is stamped with the instant the push is accepted — so the cohort is closed and reproducible, leads captured after that instant belong to auto-send, and a push can never race capture. `scope` is SELECTION (`icp` = leads with `isIcp: true`); the webhook's own `icpOnly` flag still filters at DELIVERY, so an `all` push to an ICP-only webhook queues everything and delivers the ICP subset, reporting the rest as `held`. Only ENRICHED leads are eligible — the payload is built from enrichment fields, so an un-enriched lead is never queued. Works regardless of `autoSend`: that flag governs future leads only, and `autoSend: false` means "deliver only on explicit push", i.e. this. Returns `{ pushId, counts: { selected, queued }, statusUrl, replayed, truncated, nextSince, notSelected }`. `notSelected: { awaitingEnrichment, enrichmentFailed }` counts the leads in the same scope and window that were NOT selected because they are not enriched: `awaitingEnrichment` may still be enriched (they go out through auto-send when it is on, or a later push), `enrichmentFailed` never will (enrichment ended without data). Null on a replay. A push that selects nothing is therefore not silent about why. `idempotencyKey` makes a retry a no-op that replays the first result (`replayed: true`); the same key with different arguments is a 409. `dryRun: true` counts the cohort and writes nothing. At most 2000 leads per push, oldest first — a bigger cohort returns `truncated: true` with a `nextSince` to repeat from. Delivery is at-least-once and a re-push of a delivered lead is a legitimate replay: dedupe on `data.leadId`. A push charges NO credits: it re-queues leads already captured and enriched, and re-enriches none. - POST /api/v1/company/{username}/push:the same push for a tracked company page. Identical body, semantics and response. - ⚠️ AN UNTRACKED SOURCE CANNOT BE PUSHED, on any of the four push routes: `404`, the same as a source never tracked. Untracking is a soft delete so its leads are still stored — and they are out of every DELIVERY as well as every view, because a push acts on leads that already exist and sending data the product said was gone into your CRM is the outcome deleting has to prevent. Its `/webhook` and `/icp` routes are `404` for the same reason: that config is what a push delivers to. A stopped KEYWORD search is the exception here as everywhere — its leads are kept readable, so it stays pushable and its webhook/ICP config stays editable. - POST /api/v1/keyword/{id}/push:the same push for a KEYWORD SEARCH, addressed by its source id from GET /api/v1/sources (never by its keyword text), exactly like its `/webhook`, `/icp` and `/sync` routes. - POST /api/v1/sources/{id}/push:the same push for ANY source kind, by the source `id` from GET /api/v1/sources — the only push for a TRACKED POST (backfill, and the replay after a failed delivery). Same body, response and 2,000-lead paging, and it charges NO credits (it re-queues already-enriched leads and re-enriches none); an untracked person, company page or post is a 404, a stopped keyword search is still pushable. For a tracked post it is also what `autoSend: false` holds new leads for. - GET /api/v1/push/{pushId}:progress for one push, over exactly the leads THAT push selected. Returns `{ counts: { selected, queued, pending, delivering, sending, delivered, failed, held }, status, stalled, stalledAfterSeconds, done, settled, total, percent }`. `pending` = queued and NOT YET ATTEMPTED; `delivering` = claimed by a delivery in flight (`sending` is the same number under its original name). `status` is `queued` (nothing attempted yet) | `delivering` | `delivered` | `failed` | `held`. `stalled: true` means leads are still unattempted `stalledAfterSeconds` (300) after the push was accepted — a delay on Cornersight's side, not your endpoint; a healthy push is attempted within about a minute. This is why a push is recorded: a lead's `webhookStatus` is shared by every delivery path, so `sent` on a lead cannot tell you WHICH push sent it. `held` = `no_webhook` = the delivery path had nothing to send that lead to (normally an `icpOnly` webhook rejecting a non-ICP lead) — a settled outcome, not a failure to retry. `done` is true once nothing is `pending` or `sending`. - GET /api/v1/agent:the ENGAGEMENT AGENT (`?agentId=` for one of several; a team may hold one per website): `{ agent: null, agents: [] }` until one is set up, else `agent` { id, status (draft or active), website, profile { summary, titles, industries, companySizes, countries, topics, competitors }, dailyCredits, plan { perTopic, people, total }, trial (on a free trial: { peopleChecked: 1000, maxPeople: 20 }), webhook ({ url, autoSend, filters, since } or null), launchedAt, discoverRun, peopleAdded, sources } and `agents` [{ id, website, status }]. Free. - PUT /api/v1/agent:create or PARTIALLY update the agent. Flat body `{ "agentId?", "create?", "website?", "summary?", "titles?", "industries?", "companySizes?", "countries?", "topics?", "competitors?", "dailyCredits?", "webhook?" }`; a list sent replaces that list; over a cap or a bad value is a 400 naming the field. `webhook` is `{ url, autoSend, filters }` (url a public http(s) URL; filters = the Agent Leads table's Filters rows `{ column, operator, value }`, an operator or value the column does not take is a 400) or null, saved WHOLE (omitted autoSend = false, omitted filters = none); each lead goes as the team's `lead.detected` event for that person's newest row, with no ICP % or signal: autoSend sends each NEW lead that passes the filters as it arrives (from when it is switched on); past ones go with POST /api/v1/agent/push. `create: true` adds another agent (403 `trial_agent_limit` on a free trial). Starts nothing, charges nothing. - POST /api/v1/agent/start:start or restart the agent. Body `{ "agentId?", "dailyCredits?", "confirmSpend": true }`. Without confirmSpend: 409 `spend_confirmation_required` with `estimatedDailyMax`, `plan` and `discoverCost`, nothing changed. Stops what it set up before, creates one daily keyword search per topic and a one-off people search whose people are watched when it finishes. RECURS DAILY until stopped; half the daily limit goes to the topics, the other half watches people at up to 25 credits a day each; one credit per person checked. On a FREE TRIAL there is no limit to choose: the agent checks 1,000 people in all, watches up to 20 people, and uses none of the trial's own slots or the team's daily keyword ceiling. - POST /api/v1/agent/stop:untrack every active agent source (a soft untrack: leads kept) and set the agent back to draft. Body `{ "agentId?" }`. Returns the agent and `stop { stopped, notStopped }`. - GET /api/v1/agent/leads:Agent Leads (`?agentId=`, `?filters=` as the table's Filters rows in JSON, `limit` 1-200, `offset`): ranked, one row per person: the job title must match; the rest lowers the ICP % (title 40, country 30, industry 15, company size 15; unknown earns half), 40-100; signal 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), weak (only a hiring or personal-news post); ordered by ICP %, then signal, then newest — the dashboard's Agent Leads, built by the same code. Each lead: id, name, linkedinUrl, jobTitle, company and company details, country, icp, icpReasons, signal, signalReasons, engagementCount, engagements, and the strongest engagement's engagementType, source, topic, posterTopic, postUrl, detectedAt. `checked` = everyone the team paid to check, `total` = people shown, `firstRun.done` false while the first run is still going (never longer than a day). Free. - POST /api/v1/agent/push:send Agent Leads to the agent's webhook now (the dashboard's Push historic leads). Body `{ "agentId?", "leadIds?" }`: without leadIds, every Agent Lead passing the webhook's filters; ids that are not Agent Leads are skipped. 400 `no_webhook` without one; a lead already delivered or on its way is never sent again, a failed one is retried. Returns `{ agentId, queued, considered }`. Free. - GET /api/v1/integrations/heyreach:the HEYREACH connection: `{ connected, connectedAt, keyRejected, autoPush[] }`, every auto-push rule with its scope (agentId or sourceId), campaign, autoSend, filters, since and `paused` (invalid_key or campaign_closed). Connecting the key is dashboard-only (Integrations page); no endpoint takes or returns one. - GET /api/v1/integrations/heyreach/campaigns:the team's HeyReach campaigns leads can join (in progress, paused, draft, scheduled, starting) with `accountIds` and `canTakeLeads`. 409 `not_connected` / `invalid_key`. - POST /api/v1/integrations/heyreach/send:add leads to a HeyReach campaign, where its LinkedIn sequence starts. Body `{ "campaignId", "leadIds" }` (≤1,000; `agentId` adds each Agent Lead's ICP % and signal) or `{ "campaignId", "people": [{ profileUrl, name, jobTitle, company, country }] }` for Discover influencers. Only ENRICHED leads are sent; a lead with no LinkedIn profile URL is skipped; a person is never sent twice to the same campaign; a paused campaign is never resumed. Returns sent, added, updated, failed, alreadyInCampaign, skippedNoLinkedin, notFound. Custom fields: icp_percent, signal, engaged_post_url, comment_text, source_name; the headline goes as summary. - GET /api/v1/sources/{id}/heyreach:any source's HeyReach auto-push by source id, or `heyreach: null`. - PUT /api/v1/sources/{id}/heyreach:set it: `{ "campaignId", "autoSend?", "filters?", "pushHistoric?" }` (null, or campaignId null, removes it). autoSend sends each new lead passing `filters` once enriched; pushHistoric sends the ones already found, once. A source's filters cannot use icp, signal or engagementCount. An agent's auto-push is `heyreach` on PUT /api/v1/agent. ## MCP tools — leads, enrichment & credits (same workflows over MCP) - Create-job tools return exactly `{ "jobId", "statusTool": "get_job_status" }` — no job status and never the result. The job-status enum (`pending|running|completed|failed`) exists ONLY on get_job_status responses; always call it to read the result. - TRUNCATION, AND IT IS MCP-ONLY: every array in a tool result is cut to its FIRST 10 ENTRIES before you see it and `resultTruncated` is set; the CLI and the REST API return the array whole, so a row count that looks short here is a property of this surface and not of your data. EVERY TOOL CAN NOW REACH THE REST, but the argument differs: get_job_status, list_sources, get_credits_usage and get_api_keys take `rowOffset` (10 entries per response; pass back the `nextRowOffset` from `resultWindow` until it is null — no provider call and no credits), while list_leads and list_engagers take `limit`/`offset` and now ASK the endpoint for 10 rather than showing you 10 of a bigger page — whatever `limit` you pass is lowered to 10, so `total`, `limit`, `offset` and `hasMore` all describe the rows you were handed; page by passing `nextOffset` back as `offset` until it comes back null. Until 2026-09-08 they did not: a final page of 20 rows arrived as 10 rows with `hasMore: false`, so an agent that branched on the flag — as every description tells it to — silently dropped the other 10. On these two `resultTruncated` no longer fires for the row array at all, so a true there means NESTED content was clipped, not that rows are missing. STOP ON `nextRowOffset: null`, NOT ON `resultTruncated`, which describes only the response in your hand and stays true on the last window of a long list. FILTERS ARE NOT PAGES: `type`, `includeInactive` and `from`/`to` change WHICH entries match, and `includeInactive` makes the list longer — only `rowOffset` moves the window. On a response with two arrays (get_credits_usage) one cursor drives both, and an array short enough to fit is repeated WHOLE in every window with `windowed: false` in its `resultWindow` entry — read that as "already complete", and do not sum it once per page. TOTALS BESIDE A TRUNCATED ARRAY ARE COMPUTED BEFORE THE CUT and stay correct (`total`, `totalCharged`, the key quota), so report those and never re-derive a total by summing the rows you were shown. AND TRUNCATION IS NOT ONLY ROWS: inside a row, long strings are clipped, nested arrays are cut and anything nested more than 5 deep is emptied to `[]`/`{}`, so reaching every row is not the same as reading every field — on get_job_status, `detail: true` returns one row complete instead. - create_profile_enrichment_job:enrich a LinkedIn person profile. saveTrackedProfile=true to track a new profile (required before fetching its posts). - create_company_enrichment_job:enrich a LinkedIn company page. saveTrackedProfile=true to track a new company page. - untrack_profile:stop tracking a profile — the source is DEACTIVATED, not deleted, and nothing it captured is destroyed (soft delete; `list_sources includeInactive=true` still shows it, with `status: "inactive"`). Its leads are RETAINED in the account but STOP BEING SERVED — list_leads and list_engagers both exclude them and 404 on its id, as the dashboard does. Re-tracking the same username revives that source rather than creating a second one. - untrack_company:stop tracking a company page — the source is DEACTIVATED, not deleted, and nothing it captured is destroyed (soft delete; `list_sources includeInactive=true` still shows it, with `status: "inactive"`). Its leads are RETAINED in the account but STOP BEING SERVED — list_leads and list_engagers both exclude them and 404 on its id, as the dashboard does. Re-tracking the same username revives that source rather than creating a second one. - track_post:track a single LinkedIn post as a source, by permalink, without tracking its author. - untrack_post:stop tracking a post — capture stops and the post leaves list_sources (soft delete; `includeInactive` still shows it). Its leads are retained in the account but STOP BEING SERVED — list_leads excludes them and 404s on its id, as the dashboard does. Takes the post URN, not the permalink. - track_keyword:⚠ THE SPEND MUST BE CONFIRMED BY THE PERSON, AND THE THRESHOLD IS EVERY CREATE — there is no credit figure below which it is skipped, because the charge RECURS daily. `confirmSpend: true` plus the three scope fields (creditCap, captureMode, datePosted) are the contract, on a create, on a RESUME and on any update that RAISES the daily figure. `postBudget` ("Posts per run") is retired and is NOT an argument any more: creditCap is the one limit you set, and a run reads up to 2,000 posts (an internal ceiling; with an AI filter each of those posts is sent to the team's OWN AI key, up to 2,000 in each run). Without them: `400 scope_required` (with `field`) or `409 spend_confirmation_required`, whose body carries estimatedDailyMax, remainingBalance and daysToExhaustAtCap — SHOW THOSE TO THE PERSON AND WAIT FOR AN ANSWER, then re-send the identical call with confirmSpend: true; do NOT lower the caps to get past a refusal. ⚠ THE TOOL'S `required` LIST TELLS YOU WHICH PHASE YOU ARE IN: while the contract is in its warning phase the four read as optional and an old-style call still returns 201 with a `deprecations` array naming what to add and the cut-over date (relay it); from the cut-over they are required in the schema and refused by the endpoint. ⚠ THE CUT-OVER IS SCHEDULED FOR 2026-11-01, AND THIS IS THE NOTICE OF IT. UNTIL 2026-11-01 an old-style create — one that omits the three scope fields and `confirmSpend` — 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` — 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 — 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. track a keyword search — captures leads from posts matching your terms, a source kind alongside profiles and company pages. The reply carries `estimatedDailyMax` (the most ONE DAY of it can cost in enriching credits) and `daysToExhaustAtCap` (whole days the team's balance funds that) — tell the user the daily figure before or as you create the search, because the first sweep runs within seconds and charges. Both keys are omitted, never null and never 0, when there is no honest number. WHO IT CAPTURES, four arguments with defaults: mode (engagers, the default, captures PEOPLE; posts_only stores matching posts' text, captures no people, needs postsPerSync 1-60 and confirmSpend, and is priced min(postsPerSync, creditCap)), captureEngagers (default true — people who liked or commented), captureReplies (default true — also people who reply to comments, charged as engagers; false skips them), capturePostAuthors (default false — each kept post's writer, as an Author lead). REFUSED (400): posts_only without postsPerSync, and postsPerSync in engagers mode; captureEngagers or capturePostAuthors with posts_only; captureEngagers and capturePostAuthors both false (`no_capture_target`). - estimate_keyword_search:what a keyword search WOULD cost, without creating it — call this BEFORE track_keyword, every time, and SAY THE DAILY FIGURE TO THE PERSON before you create anything. Same arguments as track_keyword; returns 200 with the resolved caps, `estimatedDailyMax`, `remainingBalance`, `daysToExhaustAtCap`, `filtering` and `resumed`. Creates nothing, resumes nothing, charges nothing, so it is safe to call twice to compare two sets of caps. It exists because an unconfirmed create is refused 409 and an error result reaches an agent as `{ statusCode, error }` with the numbers stripped — a 200 does not. The figure is the SAME one track_keyword will report for the same arguments. `resumed: true` means these keywords already name a search the team has (active or deleted) and `previous` gives its current caps — say that before calling track_keyword, and reach for update_keyword instead if changing an existing search is what was meant. The three scope arguments are OPTIONAL here and stay optional after the cut-over; confirmSpend is not an argument at all. It takes the four capture arguments too, with track_keyword's defaults and refusals: a posts_only search is priced min(postsPerSync, creditCap), an engagers one creditCap whoever it captures. Both estimate keys are OMITTED, never null and never 0, when there is no honest number. - untrack_keyword:stop tracking a keyword search — the source is DEACTIVATED, not erased (soft delete, like every other kind), so sweeping and the daily charge stop and it leaves list_sources. THE ONE KIND WHOSE LEADS STAY SERVED: the leads it captured are KEPT and stay readable by list_leads and list_engagers alike (`list_leads sourceKind='keyword'`, or its id via `list_sources includeInactive=true`), which is the opposite of what untracking a profile, company or post does. Takes the search's id, not a username. - update_keyword:change a LIVE keyword search's settings by its source id — caps, scope, AI filter, the seven targeting filters, name — without re-creating it. PARTIAL: what you omit is left alone; an explicit null clears a nullable field, [] clears a targeting filter. ⚠ RAISING THE DAILY FIGURE — creditCap, or min(postsPerSync, creditCap) on a posts_only search — needs the person's `confirmSpend: true` and is otherwise 409 `spend_confirmation_required`, which CHANGES NOTHING, names the search in its message, and carries `previous`, the resulting caps, `raised` and estimatedDailyMax/remainingBalance/daysToExhaustAtCap — say the before, the after and the daily figure, then re-send with confirmSpend: true; never shrink the cap to get past it. LOWERING a cap, or editing anything else, needs no confirmation — do not ask someone to authorise a reduction. The same gate applies to track_keyword's resume path. WHO IT CAPTURES is editable here, partial like the rest, with track_keyword's defaults and refusals: mode (switching either way needs confirmSpend), postsPerSync, captureEngagers, captureReplies, capturePostAuthors. keywords cannot be changed here (error, not a silent drop). Takes effect from the NEXT run. - create_profile_posts_job:fetch a person's posts. Optional `limit` (1-15) sizes the page. - create_company_posts_job:fetch a company page's posts. - create_post_reactions_job:fetch reactions on a post this team holds — tracked, or harvested by one of its keyword searches or seen by a posts-only watch (read only: nothing saved, no charge). - create_post_comments_job:fetch comments on ANY post this team holds — tracked, or a keyword search's or posts-only watch's (read only, no charge) — same as create_company_post_comments_job; pick either. - create_company_post_comments_job:fetch comments on ANY post this team holds — tracked, or a keyword search's or posts-only watch's (read only, no charge) — same as create_post_comments_job; pick either. - get_job_status:poll a job by id. Args: `{ jobId, rowOffset?, detail? }`. A response carries at most 10 ROWS of `result.data` whatever the job collected — `rowOffset` walks the rest, and `resultWindow` `{ rowOffset, rowsReturned, rowsInPage, nextRowOffset }` says where you are; pass `nextRowOffset` back until it is null. `rowsInPage` is the rows the job actually collected (not `total`, which is the provider's declared count). It re-reads the STORED result: no provider call, no credits. TWO LEVELS, in this order — exhaust `rowOffset`, THEN create a new job with `page` incremented, since `page` advances the provider by a whole page of 50. Stop on `nextRowOffset: null`, not on `resultTruncated`, which describes only the response you are holding. EVERY ROW IS NOT EVERY FIELD: a summary also clips a string past 2,000 characters, cuts a nested array past 10 entries and EMPTIES anything nested more than 5 deep to `[]`/`{}` — the JSON type is kept, so an empty array or object at a path named in `resultDetail.clipped` means "not shown here", never "none". `detail: true` returns the SINGLE row at `rowOffset` exactly as REST stores it — nothing clipped, cut or flattened, secrets still `[omitted]` — and `resultDetail.mode` says whether you are holding a "summary" or a "full" record. - get_sync_status:how far along a tracked source's capture sync is (stage + progress counts). - get_keyword_sync_status:the same for a KEYWORD SEARCH, addressed by its source id rather than a username. - list_sources:list the tracked sources this team monitors (people, company pages, tracked posts, keyword searches). ACTIVE only unless `includeInactive: true`, which is how a stopped keyword search's id is found again. ⚠ OVER MCP a response carries 10 sources at a time: pass `rowOffset` and follow `resultWindow.nextRowOffset` until it is null, and compare `total` (counted before the cut) against what you were shown. `type` and `includeInactive` FILTER the list rather than paging it — includeInactive makes it longer. Do not answer "what am I tracking" or look up a source id from one call when `total` exceeds the sources you hold: the id another tool needs may be the eleventh. The CLI (`sources`) and GET /api/v1/sources return every match in one response. - list_leads:list captured leads, one row per engagement; same filters and paging as GET /api/v1/leads, `sourceKind` included — `sourceKind: 'keyword'` is how you count keyword-search leads without enumerating searches. - list_raw_leads:list RAW leads — sources in raw mode (`enrichLeads: false`), captured and charged but never enriched; same filters and paging as GET /api/v1/leads/raw (`profileId`/`username`, `action`, `since`/`until`, `includeInactive`, `limit`/`offset`). 10 rows per call: follow `nextOffset`. The only tool that returns raw leads — `list_leads` and `list_engagers` never do. - get_credits_balance:enriching credit balance (balance, used, limit, remaining, plan, nextReset). - get_credits_usage:dated + by-source breakdown of enriching credits charged. Over MCP a response carries 10 entries at a time: `byDate` is the one that overflows (one entry per charged UTC day, so up to 31 in a billing period), and `rowOffset` walks it — follow `resultWindow.nextRowOffset` until null. `bySource` is short enough to fit, so it comes back WHOLE in every window (`windowed: false`) and must not be summed once per page. Narrowing `from`/`to` re-reads the ledger per slice and cannot page `bySource`; `totalCharged` is computed before the cut and remains the figure to quote. - push_leads:push a tracked profile's or company page's ALREADY-CAPTURED leads to its webhook — historical backfill (`scope: 'all'`), ICP-only (`scope: 'icp'`), or the explicit retry for failed deliveries. Optional `since`/`until` window over the lead's detectedAt, `idempotencyKey` to make a retry a no-op, `dryRun` to count without sending. Works regardless of `autoSend`. - push_keyword_leads:the same push for a KEYWORD SEARCH, addressed by its source id from `list_sources`. - push_source_leads:the same push for ANY source kind by source id — the only push for a TRACKED POST. - get_source_sync_status:sync progress for ANY source kind, by the source `id` from `list_sources` — including a TRACKED POST, whose lifecycle no other tool can follow. Same lifecycle object as get_sync_status (state, isFinal covering enrichment, `capture`, `enrichment`, `progress`). - sync_source:sync a tracked person, company page or post NOW by source id — the on-demand sync re-tracking never was. Charged like any sync; never stacks on one in flight (`queued: false`); a keyword search is refused. - get_source_webhook_config:webhook configuration for ANY source kind, by source id (URL, icpOnly, autoSend, syncEvents). get_webhook_config is the same read for a person or company page named by HANDLE. - set_source_webhook_config:configure ANY source's webhook by source id. Partial update; explicit `false` is a value, and an empty `webhookUrl` clears delivery. - get_source_icp_config:ICP filter rules and match mode for ANY source kind, by source id. - set_source_icp_config:set ANY source's ICP rules and/or match mode by source id; empty `rules` clears the filter, and existing leads are re-scored immediately. - update_profile:change a tracked person’s PER-SYNC CREDIT LIMIT by LinkedIn username, without re-tracking it — the edit that queues no sync and charges nothing, unlike calling `create_profile_enrichment_job` again with a new `creditCapPerSync`. Send any of `creditCapPerSync` (nullable: `null` removes the limit), `mode` (with `postsPerSync` for posts_only; posts-only back to engagers needs `confirmSpend: true` after the person agrees), `captureReplies` and `enrichLeads` (false = RAW MODE: captured and charged as before, never enriched, read with `list_raw_leads`); omitted settings are left as they are, and a call naming none is rejected. Read the current values from `list_sources` first if you need them. Returns `previous.creditCapPerSync` beside the new one, so you can tell the person what it WAS. NOT a keyword search’s `creditCap` — that is a per-RUN cap changed with `update_keyword`, and a keyword search is not addressable here at all. ASK BEFORE LOWERING ONE: the limit bounds capture, so a number chosen too low silently stops collecting leads the user wanted. - update_company:change a tracked company page’s PER-SYNC CREDIT LIMIT by LinkedIn username, without re-tracking it — the edit that queues no sync and charges nothing, unlike calling `create_company_enrichment_job` again with a new `creditCapPerSync`. Send any of `creditCapPerSync` (nullable: `null` removes the limit), `mode` (with `postsPerSync` for posts_only; posts-only back to engagers needs `confirmSpend: true` after the person agrees), `captureReplies` and `enrichLeads` (false = RAW MODE: captured and charged as before, never enriched, read with `list_raw_leads`); omitted settings are left as they are, and a call naming none is rejected. Read the current values from `list_sources` first if you need them. Returns `previous.creditCapPerSync` beside the new one, so you can tell the person what it WAS. NOT a keyword search’s `creditCap` — that is a per-RUN cap changed with `update_keyword`, and a keyword search is not addressable here at all. ASK BEFORE LOWERING ONE: the limit bounds capture, so a number chosen too low silently stops collecting leads the user wanted. - list_engagers:the repeat-engagement signal, one row per PERSON with `engagementCount`; same filters as GET /api/v1/engagers (`minEngagements`, `orderBy`, `profileId`/`username`, `engagementType`, `isIcp`, `since`/`until`, `includeSyncing`, `includeInactive`). - get_api_keys:list the team's active API keys, masked (never the plaintext), plus the quota `{ used, max, remaining }`. Creating and revoking keys is not available over MCP. - get_profile_urn:resolve a public LinkedIn handle to the member id (`ACoAA…`) track_keyword's `fromPerson`/`mentionsPerson` take. Free: enriches nobody, tracks nobody, `creditsCharged: 0`. - get_profile_posts:read an UNTRACKED profile's posts with their text. `{ username, posts: 1-60, confirmSpend? }`: ONE POST IS ONE CREDIT, so ask how many and get a yes before sending `confirmSpend: true`; no engagers, nothing tracked. - get_company_posts:the same priced read for an untracked COMPANY PAGE. - get_webhook_config:webhook configuration for a tracked person or company page (`profileType`), as `{ webhookUrl, icpOnly, autoSend, syncEvents }`. - set_webhook_config:set it. Partial update: only the fields passed change; an empty `webhookUrl` clears delivery. - get_keyword_webhook_config:the same read for a KEYWORD SEARCH, by its source id. - set_keyword_webhook_config:the same partial write for a keyword search, by source id. - get_icp_config:ICP criteria (rules + match mode) for a tracked person or company page (`profileType`). - set_icp_config:set them. Partial (`rules` and/or `matchMode`); `rules: []` clears the filter and existing leads are re-scored. `dryRun: true` previews `counts` and changes nothing. - get_keyword_icp_config:the same read for a KEYWORD SEARCH, by its source id. - set_keyword_icp_config:the same write for a keyword search, by source id, `dryRun` included. - get_kept_posts:WHICH posts a keyword run swept and KEPT, with permalinks — the identities behind `lastRun.postsKept`, and the URNs to hand to create_post_reactions_job. Keyword sources only (any other kind is a 404). `scope` says which question was answered: "run" is that run's own list, "prompt" the wider fallback (every post kept under the current prompt, across every run of it). `keptPosts` is NULL with a `reason`, never an empty array, only on scope "prompt" when nothing recorded a kept list; [] appears only on scope "run" and means that run kept nothing. - get_source_posts: the posts a posts-only source (keyword search, person or company page) has saved, newest first, with text, permalink, author and counts; read-only and free; 10 per response, page with rowOffset (GET /api/v1/sources/{id}/posts) - discover_influencers:start a ONE-OFF Discover Influencers run — the people who POST about 1-10 keywords (any may match) over the past month and average at least `minEngagement` likes + comments per matching post; at most `maxInfluencers` (1-500; up to 100 on a free trial), optional `countries` (up to 20). 1 credit per person added, once. Call WITHOUT confirmSpend first: the 409 carries `details.estimatedCredits`; say it, wait for a yes, then re-send with `confirmSpend: true`. - list_discover_runs:the team's Discover runs, newest first — state, candidates, qualified, found, countries, countryChecked, countryMatched, createdAt. - get_discover_run:one Discover run and its influencers (name, LinkedIn URL, job title, company, country, highestEngagement, avgEngagement, postCount, top post URL and text), by run id. - delete_discover_run:soft-delete a Discover run by id — it and its influencers leave the lists; the Author leads it added are kept; no refund. - get_lead_push:progress for one push by its pushId — pending / sending / delivered / failed / held over exactly that push's cohort, plus `done` and `percent`. - get_agent:read an Engagement Agent (profile, daily limit and split, status, webhook, trial allowance, sources) and `agents`, every agent the team holds; `agentId` picks one. Flattened so every list pages with `rowOffset`; `{ agent: null, agents: [] }` until set_agent_profile creates one. - set_agent_profile:create or partially update an agent's profile, daily limit and webhook ({ url, autoSend, filters }) and HeyReach auto-push (`heyreach` { campaignId, autoSend, filters, pushHistoric }); `create: true` adds another agent. Starts nothing, charges nothing. - start_agent:start or restart the agent. Refused 409 `spend_confirmation_required` (figures in `details`) until the person confirms the daily total; it recurs daily until stop_agent, and the people search's people are added automatically when it finishes. - stop_agent:untrack every source the agent set up and set it back to draft; leads are kept. - list_agent_leads:Agent Leads, ranked, one row per person: the job title must match; the rest lowers the ICP % (title 40, country 30, industry 15, company size 15; unknown earns half), 40-100; signal 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), weak (only a hiring or personal-news post); ordered by ICP %, then signal, then newest — the dashboard's Agent Leads, built by the same code; `filters` as the table's Filters rows; 10 per page with `nextOffset`; `checked` is everyone the team paid to check; `firstRun.done` false while the first run is going. - push_agent_leads:send Agent Leads to the agent's webhook now: those passing its filters, or `leadIds`. Confirm with the person first; charges nothing. - get_heyreach_status:whether HeyReach is connected, whether it rejected the key, and every auto-push rule with its state. No tool takes a key. - list_heyreach_campaigns:the HeyReach campaigns leads can join, with canTakeLeads. - send_to_heyreach:add `leadIds` (or Discover `people`) to a HeyReach campaign. Refused 409 `confirmation_required` until the person confirms and you call again with `confirm: true`. Only ENRICHED leads are sent; a lead with no LinkedIn profile URL is skipped; a person is never sent twice to the same campaign; a paused campaign is never resumed. - get_source_heyreach_config:any source's HeyReach auto-push by source id. - set_source_heyreach_config:send a source's new leads to a HeyReach campaign automatically (`campaignId` null removes it; pushHistoric sends past ones once; confirm with the person first). ## CLI commands — leads & enrichment - enrich-profile --api-key cs_ --username [--save-tracked-profile] [--no-wait] [--credit-cap-per-sync ] [--mode ] [--posts-per-sync <1-60>] [--first-sync-posts <1-50>] [--first-sync-days <1-90>] [--confirm-spend] [--capture-replies ] [--enrich-leads ] (--credit-cap-per-sync, --enrich-leads and the two --first-sync-* flags need --save-tracked-profile; --first-sync-posts / --first-sync-days shape the FIRST sync only (latest N posts, default 15; posts of the last N days, at most 50 or --first-sync-posts), are refused with --mode posts-only, and require cornersight-cli >= 4.10.0; posts-only needs a saved source and explicit spend confirmation; reply capture defaults on; the capture-replies flag requires cornersight-cli >= 4.4.0; --no-enrich-leads keeps the source's leads raw, read with leads-raw, and requires cornersight-cli >= 4.7.0) - enrich-company --api-key cs_ --username [--save-tracked-profile] [--no-wait] [--credit-cap-per-sync ] [--mode ] [--posts-per-sync <1-60>] [--first-sync-posts <1-50>] [--first-sync-days <1-90>] [--confirm-spend] [--capture-replies ] [--enrich-leads ] (same local refusals and first-sync rules; reply capture defaults on; the capture-replies flag requires cornersight-cli >= 4.4.0; --enrich-leads requires cornersight-cli >= 4.7.0) - sync-status --api-key cs_ --username (tracked-source sync progress) - company-sync-status --api-key cs_ --username - untrack-profile --api-key cs_ --username (requires cornersight-cli >= 0.3.0) - untrack-company --api-key cs_ --username (requires cornersight-cli >= 0.3.0) - track-post --api-key cs_ --post-url [--credit-cap-per-sync ] [--capture-replies ] [--enrich-leads ] (reply authors are captured by default; false skips their lead rows and credits; requires CLI 4.4.0. --no-enrich-leads keeps the post's leads raw — captured and charged, never enriched, read with leads-raw; requires CLI 4.7.0) - untrack-post --api-key cs_ --urn - track-keyword --api-key cs_ [--keywords | --expression ''] --credit-cap --capture-mode [--capture-engagers ] [--capture-post-authors ] --date-posted --confirm-spend [--name ] [--capture-replies ] [--enrich-leads ] [--sort ] [--content-type ] [--max-engagements-per-post ] [--ai-provider

] [--ai-model ] [--ai-prompt

] [--author-industry ] [--author-company ] [--author-keyword ] [--from-person ] [--from-company ] [--mentions-person ] [--mentions-company ] [--run-once] [--end-at ] [--max-runs <1-3650>] [--dry-run] (⚠ EXACTLY ONE OF --keywords AND --expression: both is a 400 and neither is a 400. --expression is a boolean search string — --expression 'hiring AND "sales ops" NOT recruiter OR fundraising' — where NOT binds tighter than AND, which binds tighter than OR, there are NO PARENTHESES and operators are UPPER CASE ONLY; only OR is served by the search itself, so it costs exactly what the same terms as a list cost and what changes is the yield. ⚠ --run-once/--end-at/--max-runs BOUND THE DAILY RECURRENCE, which is otherwise forever: --max-runs counts only runs that reached the provider and ended ordinarily, and --end-at must be in the FUTURE. --dry-run drops all three (a dry run creates nothing and the schedule cannot move the price). ⚠ --dry-run PRICES THE SEARCH AND CREATES NOTHING: it runs `keyword-estimate` with the same flags, exits 0 with the estimate on stdout, and needs neither the scope flags nor --confirm-spend — run it first and read `estimatedDailyMax` out. ⚠ SINCE 3.0.0 the scope flags (three since --post-budget was retired on 30 September 2026; it is still accepted and ignored) are REQUIRED and --confirm-spend is mandatory: without it the command PRINTS WHAT ONE DAY CAN COST AND EXITS 1 having opened no connection — no search, no sweep, no charge. --no-confirm-spend is the explicit no and exits 1 the same way. The dashboard pre-fills 100 / depth / PAST_WEEK; copy those deliberately rather than expecting a default, because there is none. Reply authors are captured by default; the opt-out is available in CLI 4.4.0.) - untrack-keyword --api-key cs_ --id - keyword-update --api-key cs_ --id [--name ] [--capture-replies ] [--enrich-leads ] [--date-posted ] [--sort ] [--content-type ] [--credit-cap ] [--capture-mode ] [--capture-engagers ] [--capture-post-authors ] [--max-engagements-per-post ] [--ai-provider

] [--ai-model ] [--ai-prompt

] [--author-industry ] [--author-company ] [--author-keyword ] [--from-person ] [--from-company ] [--mentions-person ] [--mentions-company ] [--run-once] [--end-at ] [--max-runs <1-3650>] [--confirm-spend] (⚠ NO --expression HERE: an expression is create-only, because the seen-set is keyed to the SEARCH and a new one would inherit the posts the old one rejected — the same reason --keywords is not here either. ⭐ --max-runs ABOVE the runs already completed RESTARTS a stopped search: this call clears the stop AND re-queues it, so the next sweep is within about a minute and the daily charge resumes. PARTIAL — only flags you give change. Reply authors are captured by default; the opt-out requires CLI 4.4.0.) - keyword-estimate --api-key cs_ [--keywords | --expression ''] [--name ] [--date-posted ] [--sort ] [--content-type ] [--credit-cap ] [--capture-mode ] [--capture-engagers ] [--capture-post-authors ] [--max-engagements-per-post ] [--ai-provider

] [--ai-model ] [--ai-prompt

] [--author-industry ] [--author-company ] [--author-keyword ] [--from-person ] [--from-company ] [--mentions-person ] [--mentions-company ] (PRICES A SEARCH WITHOUT CREATING IT — exit 0, the estimate as the usual JSON envelope on stdout, nothing created, resumed or charged. `cornersight track-keyword --dry-run` is the same request under the command you already know. The three scope flags are OPTIONAL here, unlike on track-keyword: this is the call you make in order to decide them, and an omitted one resolves to the live search's value or the default. Read `estimatedDailyMax` out to whoever asked before you run track-keyword; `resumed: true` means these keywords already name a search you have and `previous` is what it is capped at now.) - keyword-get-webhook --api-key cs_ --id - keyword-get-icp --api-key cs_ --id - keyword-set-icp --api-key cs_ --id [--match-mode ] [--rules ] [--dry-run] - keyword-sync-status --api-key cs_ --id - keyword-set-webhook --api-key cs_ --id [--webhook-url ] [--icp-only] [--auto-send] [--sync-events] (each toggle takes an OPTIONAL value: `--icp-only`, `--icp-only true` and `--icp-only=true` all send true; `--no-icp-only`, `--icp-only false` and `--icp-only=false` all send false; anything else — `--icp-only=yes` — is refused by name rather than guessed at. Naming neither omits the field, so the PUT stays a partial update and the stored value is left alone. Every boolean body flag across the CLI works the same way — `--help` lists them under `negatableFlags`, with the accepted spellings under `booleanFlagForms`) - profile-posts --api-key cs_ --username [--limit <1-15>] [--pagination-token ] [--no-wait] - company-posts --api-key cs_ --username [--pagination-token ] [--no-wait] - profile-posts-read --api-key cs_ --username --posts <1-60> [--confirm-spend] (one credit per returned post; no confirmation returns JSON 409 without charging; requires cornersight-cli >= 4.3.0, not yet published according to the release record; old --limit/--pagination-token are rejected) - company-posts-read --api-key cs_ --username --posts <1-60> [--confirm-spend] (same priced one-off read and release requirement as profile-posts-read) - post-reactions --api-key cs_ --post-urn [--page ] [--source ] [--no-wait] (echo the previous page's `source` on later pages of one sweep; cornersight-cli 4.12.0) - post-comments --api-key cs_ --post-urn [--page ] [--size <1-50>] [--include-replies ] [--no-wait] - company-post-comments --api-key cs_ --post-urn [--page ] [--size <1-50>] [--include-replies ] [--no-wait] - job-status --api-key cs_ --job-id - sources --api-key cs_ [--type ] [--include-inactive ] - leads-list --api-key cs_ [--limit ] [--offset ] [--profile-id ] [--username ] [--engagement-type ] [--is-icp] [--webhook-status ] [--since ] [--until ] [--name ] [--job-title ] [--company ] [--company-domain ] [--company-industry ] [--country ] [--include-syncing] [--include-inactive ] [--source-kind ] - leads-raw --api-key cs_ [--limit ] [--offset ] [--profile-id ] [--username ] [--action ] [--since ] [--until ] [--include-inactive ] (raw leads of sources in raw mode — captured and charged, never enriched — which leads-list never returns; requires cornersight-cli >= 4.7.0) - `leads-list` and `engagers-list` return `companyName` (also `company`), `companyUrl` (website URL), `companyDomain` (hostname), `companyLinkedinUrl` (LinkedIn company page), `companyDescription`, `companyIndustry`, `companyLocation` (headquarters), `companyEmployeeCount`, `companyStaffRange` and `companyEnrichedAt`. 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. companyDescription and companyLocation (headquarters) come from the company record, resolved once per company and cached for 6 months, at no enriching-credit cost. Unknown values are null. - credits --api-key cs_ (requires cornersight-cli >= 0.3.0) - credits-usage --api-key cs_ [--from ] [--to ] (requires cornersight-cli >= 0.3.0) - push-leads --api-key cs_ --username [--scope ] [--since ] [--until ] [--idempotency-key ] [--dry-run] (push already-captured leads to the profile's webhook; requires cornersight-cli >= 2.9.0) - company-push-leads --api-key cs_ --username [--scope ] [--since ] [--until ] [--idempotency-key ] [--dry-run] - keyword-push-leads --api-key cs_ --id [--scope ] [--since ] [--until ] [--idempotency-key ] [--dry-run] - source-push-leads --api-key cs_ --id [--scope ] [--since ] [--until ] [--idempotency-key ] [--dry-run] - source-posts --api-key cs_ --id [--limit <1-200>] (the posts a POSTS-ONLY profile or keyword watch has seen, newest first by when WE saw them — the poll beside the `post.detected` push. Reading charges NOTHING: the credit was spent when the post was first fetched. 404 `not_posts_only` on any other kind of source, which names where that kind's posts ARE reported.) - source-sync-status --api-key cs_ --id (sync progress for ANY source kind, tracked posts included) - source-sync --api-key cs_ --id (sync a tracked person, company page or post now, charged like any sync; a keyword search is refused; cornersight-cli 4.9.0) - source-get-webhook --api-key cs_ --id - source-set-webhook --api-key cs_ --id [--webhook-url ] [--icp-only ] [--auto-send ] [--sync-events ] (partial update; an empty --webhook-url clears delivery) - source-get-icp --api-key cs_ --id - source-set-icp --api-key cs_ --id [--match-mode ] [--rules ] [--dry-run] - profile-update --api-key cs_ --username [--credit-cap-per-sync ] [--mode ] [--posts-per-sync <1-60>] [--confirm-spend] [--capture-replies ] [--enrich-leads ] (--mode switches the source's sync mode from cornersight-cli 4.12.0, and posts-only back to engagers needs --confirm-spend; change a tracked person’s per-sync credit limit, reply capture or raw mode without re-tracking it; PASS --credit-cap-per-sync WITH NO VALUE to send `creditCapPerSync: null` and REMOVE the limit, which no integer flag can spell; --no-enrich-leads keeps its leads raw, read with leads-raw, from cornersight-cli 4.7.0; passing none of the three changes nothing and is an error) - company-update --api-key cs_ --username [--credit-cap-per-sync ] [--mode ] [--posts-per-sync <1-60>] [--confirm-spend] [--capture-replies ] [--enrich-leads ] (--mode switches the source's sync mode from cornersight-cli 4.12.0, and posts-only back to engagers needs --confirm-spend; change a tracked company page’s per-sync credit limit, reply capture or raw mode without re-tracking it; PASS --credit-cap-per-sync WITH NO VALUE to send `creditCapPerSync: null` and REMOVE the limit, which no integer flag can spell; --no-enrich-leads keeps its leads raw, read with leads-raw, from cornersight-cli 4.7.0; passing none of the three changes nothing and is an error) - engagers-list --api-key cs_ [--limit ] [--offset ] [--min-engagements ] [--order-by ] [--profile-id ] [--username ] [--engagement-type ] [--is-icp ] [--since ] [--until ] [--include-syncing ] [--include-inactive ] (one row per PERSON with engagementCount) - kept-posts --api-key cs_ --id [--include ] (which posts a keyword run KEPT; --include swept returns every post it considered with per-post engager and lead counts, from cornersight-cli 4.12.0) - profile-urn --api-key cs_ --username (the member id track-keyword's --from-person/--mentions-person take; free) - get-webhook --api-key cs_ --username - set-webhook --api-key cs_ --username [--webhook-url ] [--icp-only ] [--auto-send ] [--sync-events ] (partial update) - company-get-webhook --api-key cs_ --username - company-set-webhook --api-key cs_ --username [--webhook-url ] [--icp-only ] [--auto-send ] [--sync-events ] - get-icp --api-key cs_ --username - set-icp --api-key cs_ --username [--match-mode ] [--rules ] [--dry-run] - company-get-icp --api-key cs_ --username - company-set-icp --api-key cs_ --username [--match-mode ] [--rules ] [--dry-run] - keys-list --api-key cs_ (active keys, masked, plus the quota) - keys-create --api-key cs_ --name