Cornersight API
Cornersight turns LinkedIn engagement into enriched leads. The same workflows are available three ways: a REST API, an MCP server for agents, and a CLI.
Base URL
https://app.cornersight.io
Auth
X-API-Key: cs_<your-key>
The enrichment and post/engagement endpoints are asynchronous: they return a jobId. Poll GET /api/v1/jobs/{jobId}/status until the job is completed or failed. Engagement jobs are processed inline, so they're typically already completed on your first poll (unlike enrichment/posts jobs, which may take longer). Enrichment charges one credit when a person is first enriched for a source. Later engagements by that person in the same source, and failed or empty results, are not charged again.
Authentication
Generate an API key in Cornersight → Settings → API key. Keys use the cs_ prefix. Send it on every request via the X-API-Key header. The team must have an active subscription or an unexpired trial, otherwise requests return 403. MCP clients point at https://mcp.cornersight.io/mcp and either sign in to Cornersight when the client asks, or send this same key as a header, exactly as you would here. See the MCP guide.
X-API-Key: cs_your_key_hereConcepts: tracked sources & leads
Tracked profile: a LinkedIn person or company you monitor as a source. Cornersight collects its posts and the people who engage with them.
Capping what one sync may spend. A person, company page or tracked post carries an optional creditCapPerSync. The dashboard calls it Credit limit per sync and says of a value you set that it collects at most N lead rows per sync, every sync; billing is one credit per newly enriched person per source, so repeat engagements can add rows without another charge. Left blank there is no limit, and every sync collects every engagement it finds, which is the state of every source nobody set one on. Per sync, every sync is the whole rule: it bounds each of the daily syncs, not the first pull only, and it is never a lifetime total. Set it with creditCapPerSync on POST /api/v1/enrich/profile or POST /api/v1/enrich/company — with saveTrackedProfile: true, because a cap needs a tracked source to sit on — or on POST /api/v1/post/track; change it without re-syncing and without being charged with PATCH /api/v1/profile/{username} or PATCH /api/v1/company/{username}; and read it back on GET /api/v1/sources, where null means no limit. A sync that ended on it reports stoppedBy: "credit_cap" — an ordinary ending, not a failure.
It is not a keyword search's creditCap, and the difference is three gates it does not have. A profile cap has no estimate, no confirmation gate and no team ceiling: nothing prices a sync before you set the cap, raising or lowering it never needs confirmSpend, and the team's dailyCeiling does not count a credit of it. A keyword search's creditCap has all three, because it is the daily bound on a recurring sweep rather than on one sync: POST /api/v1/keyword/estimate prices it as estimatedDailyMax, confirmSpend gates a create or a raise with a 409 spend_confirmation_required, and the team's dailyCeiling stops it. dailyCeiling counts keyword spend only, so no number of profile syncs can ever reach it. Do not send creditCap to an enrichment endpoint — the two fields shared that name until 4.0.0 and there is no alias, so a body still carrying it is a 400 with code: "renamed_field" rather than a 200 that discarded your limit.
Tracked post: a single LinkedIn post monitored as a source in its own right. Its reactors and commenters become leads, and it re-syncs on the same cadence a profile does — so engagement that arrives days after publication is still captured. A tracked post is created in the Cornersight dashboard or with POST /api/v1/post/track (CLI: track-post · MCP: track_post) — give it the post's permalink, which is resolved against LinkedIn before anything is stored. Once it exists it appears in GET /api/v1/sources with type: "post", it is filterable with ?type=post, its engagers are returned by GET /api/v1/leads like any profile's, and it is untracked with DELETE /api/v1/post/{urn} (CLI: untrack-post · MCP: untrack_post), which stops capture and keeps the leads already collected.
Large posts stop at the provider's ceiling, about 1,100 people. Measured on 29 September 2026: on a public post declaring 3,490 reactions, the data provider served about 1,100 reactors over 22 pages and then only empty pages, while every page still declared 3,490. Neither POST /api/v1/post/track nor POST /api/v1/post/reactions can get the rest — no paging, retry or creditCapPerSync changes it. The pull says so with hasMore: false and exhausted: false on its last page, and the tracked post's sync ends with capture.stoppedBy: "provider_limit" and capture.coverage showing declared and captured side by side. A post under the ceiling is captured in full and ends exhausted.
One limitation worth knowing. A tracked post cannot use the username-keyed sync-status, webhook-config or ICP-config endpoint: those routes (/api/v1/profile/{username}/sync, /webhook, /icp and the /company/ forms) cover person and company sources, so a post's URN or id is refused there with a 400 or 404 that names the route to use. A post is addressed by its source id — the id GET /api/v1/sources reports — on GET /api/v1/sources/{id}/sync, GET/PUT /api/v1/sources/{id}/webhook and /icp, POST /api/v1/sources/{id}/push, and POST /api/v1/sources/{id}/sync to sync it now (CLI: source-sync-status, source-sync, source-get-webhook/source-set-webhook, source-get-icp/source-set-icp, source-push-leads · MCP: get_source_sync_status, sync_source, get_source_webhook_config/set_source_webhook_config, get_source_icp_config/set_source_icp_config, push_source_leads). Its webhook takes webhookUrl, icpOnly and autoSend exactly as any source's does, while syncEvents: true is a 400 (not_supported_for_post) because no sync lifecycle event is sent for a post. The push is the only one a post has: it delivers the post's already-captured leads — the ones captured before its webhook was set, a retry after a failed delivery, or what autoSend: false held — and charges no credits.
Tracked keyword search: a saved query rather than a named source. It finds LinkedIn posts by keyword — up to ten terms, whose results are merged so no single term takes the whole run — optionally filters them with your own AI key and prompt, and captures the engagers of the posts that survive the filter as leads. It re-runs daily, resuming from where the last run stopped rather than re-scanning what it already has. A keyword search is created in the Cornersight dashboard or with POST /api/v1/keyword/track (CLI: track-keyword · MCP: track_keyword). Once it exists it appears in GET /api/v1/sources with type: "keyword", it is filterable with ?type=keyword, its engagers are returned by GET /api/v1/leads like any profile's, and it is untracked with DELETE /api/v1/keyword/{id} (CLI: untrack-keyword · MCP: untrack_keyword), which stops sweeping and keeps the leads already collected.
Who a keyword search captures. Four settings decide it — the dashboard's What to capture panel — and each has a default that applies when you send nothing. The people who liked or commented (captureEngagers, default true). The people who reply to comments on those posts (captureReplies, default true): reply authors are captured and charged exactly like any other engager, so leaving it on means paying for every new person who replies to a comment on a kept post, and false skips them before any lead is written or any credit charged. The person who wrote each kept post (capturePostAuthors, default false). And which of the two modes the search is in, mode — below. At least one of captureEngagers and capturePostAuthors must be true; both false is a 400 no_capture_target. An author costs what an engager costs: one credit per new person, repeats free. A post written by a company page captures no author and costs nothing (lastRun.companyAuthorsSkipped). With engagers off no reactions or comments are fetched and a run adds at most one new person per kept post; the day is still bounded by creditCap, the one limit you set. Author leads carry engagementType: "Author" on leads, webhooks and CSV. All four read back on GET /api/v1/sources: mode and captureReplies on the source, config.captureEngagers and config.capturePostAuthors in its config.
Engagers or Posts only. mode chooses what a search is for. engagers (the default, the dashboard's Engagers) captures people as leads, as above. posts_only (the dashboard's Posts only) captures no people at all: it stores the text of each new matching post — read them with GET /api/v1/sources/{id}/posts, or receive each as a post.detected webhook — and charges one credit per new kept post; a post already seen, or rejected by your AI filter, is free. It needs postsPerSync, a whole number from 1 to 60 — the most new posts one day may buy — and a posts_only request without it is a 400; postsPerSync is refused in engagers mode. Its daily figure is the lower of postsPerSync and creditCap, and that is the figure the estimate, the create and its confirmation all quote. The capture choices above do not apply: captureEngagers and capturePostAuthors are refused with posts_only. A posts-only run that buys its whole allowance ends with lastRun.stoppedBy: "post_limit"; credits there means creditCap was the lower of the two and was reached.
Reaching a stopped search's leads. Untracking is a soft delete: the search leaves GET /api/v1/sources, which lists only active sources, so its id stops being discoverable while its leads are still there. Two parameters close that. GET /api/v1/leads?sourceKind=keyword counts and lists every keyword lead the team has, stopped searches included — the one-call answer to “how many leads have my keyword searches produced”, and the same set the dashboard's keyword leads page shows. For a per-search breakdown, GET /api/v1/sources?includeInactive=true lists the untracked searches too, each with status: "inactive", and their ids still work as profileId. Without either, a sum over the active sources under-reports by exactly the searches the team has stopped.
Keyword searches are the exception, not the rule. Untracking a person, a company page or a post also deactivates rather than deletes the source, and those leads stop being served by default: they leave GET /api/v1/leads and the source's id returns 404 there, matching what the dashboard shows and what each of those delete dialogs promises. The difference is that a keyword search needs no flag, while the other three need one: GET /api/v1/leads?includeInactive=true (and the same parameter on /api/v1/engagers) reads back what an untracked person, company page or post captured, and makes its id resolve instead of 404. On GET /api/v1/sources the same parameter only lists the source — that endpoint names sources, it does not serve leads.
The same limitation applies. A keyword search cannot use the username-keyed sync-status, webhook-config or ICP-config endpoint either — those routes cover person and company sources, so a keyword search's id or text is refused there with a 400 or 404 naming the route to use, exactly as a post's is. It is addressed by its source id: /api/v1/keyword/{id}/sync, /webhook, /icp and /push, or the /api/v1/sources/{id}/… routes that take every kind.
The AI key is not supplied through the create route
POST /api/v1/keyword/track accepts an AI provider and a prompt, but never a key — sending one is a 400, not a field we quietly ignore. The key is stored once per team per provider, encrypted, in the dashboard's AI filtering panel on Keyword Engagement — the same panel that sets the provider and the prompt, both when you create a search and when you edit one. That panel lists which providers your team already has a key for, so you can confirm one is saved without re-entering it. Every keyword search on that provider uses it: there is no per-search key to set here, and saving one for a team replaces the key its other searches were already using. There is no separate integrations page — this page used to send readers to one, and no such page has ever existed.
aiModel is optional, and what runs when you omit it is this. aiProvider picks whose key filters your posts; aiModel pins which of that provider's models does it, and is the only one of the two you can leave out. Omit it and the search runs on the provider's default below — a cheap, fast current model from each vendor, since the filter is a yes/no judgement against your prompt, run on your key and your bill.
What the AI filter costs, and what bounds it. Every post a run scans is sent to your AI provider on your own key and billed by that provider, not in Cornersight credits — so creditCap does not bound it. What bounds it is the run: up to 2,000 posts in each run, sent 10 to a call (about 200 calls at most), or one call per post when your model's answer for a batch cannot be read. A post already judged under the same prompt is never sent again, so a search in its steady state only sends the new posts each day. The 2,000 is a safety ceiling on every run, not a setting.
openai—gpt-6-lunagrok—grok-4.3gemini—gemini-3.5-flash-liteclaude—claude-haiku-4-5-20251001
When the AI filter fails, read aiErrorCode, not the prose. A run that stops at the filter reports stoppedBy: "ai_error" with a sentence in lastRun.reason and a stable token in lastRun.aiErrorCode: model_not_found (the provider will not serve the model this search names — clear aiModel or set one your account can reach), invalid_key (the stored key was refused, or none is saved), rate_limited (throttled, or that key's quota is spent — the next daily run tries again), out_of_credit (your account with the AI provider has no credit left — add credit or check billing with the provider; the key itself is fine), and provider_error. That last one is the provider's own failure, not a problem with your key — rotating a working credential during an outage is the one thing that will not help. The first four are yours to fix. The provider's raw response is kept in our logs and is returned by nothing.
Omitting it and pinning today's default are different requests. An omitted aiModel follows this table as vendors retire models; a pinned id stays exactly as you sent it, and once that id is gone the run stops with stoppedBy: "ai_error" rather than quietly falling back. The id is not validated at creation — it is checked by the provider on the first sweep — so a typo is accepted by POST /api/v1/keyword/track and surfaces hours later on the run. Read either back on GET /api/v1/sources: config.aiModel is what the search pins (null when you omitted it) and config.aiModelEffective is the id the next run will actually send.
It runs every day, and it charges every day. A keyword search is a standing commitment rather than a one-off query: the sweep repeats roughly every 24 hours until you untrack it, and creditCap applies per run, not to the life of the search. The first sweep starts within seconds of creation, not a day later, and the daily cadence runs from there. A creditCap of 2,000 is up to 2,000 enriching credits every day, indefinitely — not 2,000 once. Set it to what a single day may cost, and untrack the search when you are done with it.
…unless you tell it when to stop. A search recurs daily until you untrack it, which is what every search made before these controls does. Three fields bound that, on POST /api/v1/keyword/track and editable afterwards with PATCH /api/v1/keyword/{id}:
| Field | Type | Meaning |
|---|---|---|
runOnce | boolean | Harvest once, then stop. Default false. A failed run does not satisfy it — this means one harvest, not one attempt. |
endAt | ISO 8601 (UTC) | Stops scheduling after this instant. Must be in the future — a date already past is a 400, because it would create a search parked before it ever ran. |
maxRuns | integer 1–3650 | Stops after this many completed runs. Raise it above schedule.runsCompleted to restart a stopped search. |
A run counts when it reached the provider and ended ordinarily. A failed run (error, ai_error), a run an untrack abandoned, and a run skipped before the provider was even asked (a spent team ceiling, a full lead cap — both report postsScanned: 0) do not count. A sweep that ran and captured nobody does: that is the steady state of a healthy search, and not counting it would make maxRuns: 5 mean “five runs that found something” — a budget that never expires. Read the whole picture back as schedule on GET /api/v1/sources: { runOnce, endAt, maxRuns, runsCompleted, stoppedAt, stoppedReason }.
A search whose schedule has ended keeps its leads and stays in your list. Its next run is empty — nextSyncAt is null — and the row says which control stopped it, as schedule.stoppedReason: run_once, end_at or max_runs. That is deliberately not 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, and overwriting that run's outcome would destroy the answer to “how did my last run do”. Restarting is one call: PATCH /api/v1/keyword/{id} with a maxRuns above the runs already completed clears the stop and queues the search, so the next sweep runs within a minute rather than never.
AND, OR and NOT
Instead of a list of terms, a search can be created from a boolean expression — hiring AND "sales ops" NOT recruiter OR fundraising. Send keywords or expression, never both; both together is a 400 and so is neither. The grammar has no parentheses. NOT binds tighter than AND, which binds tighter than OR, so an expression is already in disjunctive normal form and a AND b OR c means (a AND b) OR c. A parenthesis is a 400 that explains this, except in a search's own canonical expression, which can be sent back unchanged to recreate it; no term may contain one, in quotes or in a keywords list. Operators are UPPER CASE ONLY — a lower-case and is an ordinary search term, which is what stops an existing term like sales and marketing changing meaning. The implicit operator between two bare terms is OR (what a multi-term search has always been), except immediately before NOT, where it is AND, so hiring NOT recruiter means what every search engine makes it mean. A quoted phrase is one term; curly quotes count. At most 1000 characters, 60 tokens, 200 characters per term and 10 harvest terms.
A quoted phrase is matched as a phrase. This applies in every expression, including a phrase on its own or inside an OR branch. A quoted term matches only when its words appear next to each other and in that order, ignoring case and treating any punctuation between them as a space. "sales ops" matches our sales-ops lead and does not match sales, finance, ops, and tech — which, until 2026-09-21, it did in mixed expressions. An unquoted term is a single word by construction, since whitespace separates terms, and it matches as a whole word anywhere in the post: NOT art drops posts about art, not posts about a startup. LinkedIn still receives one search for each required term and may return broad candidates; Cornersight checks quoted phrases in the returned posts before anything is kept or charged. A plain keyword list remains broad, including its multi-word keywords.
Only OR is served by the search itself, and that is the whole cost story. OR is the per-term fan-out a keyword list already is, so it is free. AND and NOT cannot be pushed upstream — the provider takes one keyword string and documents no boolean syntax — so they are applied afterwards, by reading each post's own text. An AND/NOT expression therefore makes exactly as many provider searches as an OR search over the same distinct terms; what changes is the yield. Two things bound that: the run applies the filter before it fills the posts it will scan, so that scan is spent on posts that survive rather than posts about to be discarded, and credits are only ever spent 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 — and it is recorded: counted in lastRun.postsScanned and not in lastRun.postsKept, counted again by lastRun.discardedByExpression, and returned one row per post by GET /api/v1/sources/{id}/kept-posts?include=swept with outcome: "discarded" and a reason naming the part of the expression it failed, such as missing phrase "sales ops" or contains recruiter. For a feature that works by discarding, that list is the thing to read when a run keeps less than you expected. A run that kept nothing still returns those rows: its keptPosts is empty, while sweptPosts lists every discarded post with its reason and the post's LinkedIn link. On a search with no AI filter the counts reconcile exactly: postsScanned = postsKept + discardedByExpression. A post the AI filter rejects is listed the same way, with the reason AI filter rejected this post followed by the provider and model that judged it. A run recorded before 21 September 2026 counted its discards without listing them; its answer says so in unlistedDiscards rather than returning a list that looks complete.
| You type | Read as | Searched on LinkedIn | Applied afterwards |
|---|---|---|---|
hiring sdr | hiring OR sdr | hiring, sdr | — |
hiring AND sdr | hiring AND sdr | hiring, sdr | sdr |
hiring AND "sales ops" | hiring AND "sales ops" | hiring, sales ops | "sales ops", as a phrase |
hiring NOT recruiter | hiring AND NOT recruiter | hiring | NOT recruiter |
a AND b OR c | (a AND b) OR c | a, b, c | b |
sales and marketing | sales OR and OR marketing | sales, and, marketing | — |
The last row is not a joke: sales and marketing is three OR'd terms, because the operator is lower case. That is the case the upper-case rule exists for — every search anyone has already saved keeps meaning exactly what it meant. A malformed expression is a 400 at save time, naming the token at fault, and nothing is stored: NOT recruiter on its own has nothing to search for, and so does an unbalanced quote or a dangling operator. The expression compiles to keywords — its required terms, de-duplicated, in first-appearance order — and that is what GET /api/v1/sources reports as keywords, with config.expression beside it in canonical form: (hiring AND NOT recruiter) OR fundraising, where the precedence is finally visible. Read it back to confirm the operators parsed as you meant. lastRun.discardedByExpression — and lastRun.postsFilteredOut, the same number under its older name — is how many harvested posts the expression discarded: postsHarvested: 0 with discardedByExpression: 47 is an expression to rewrite, and postsHarvested: 0 with the key absent is a search that found nothing. The counts say how many; kept-posts?include=swept says which ones, and why each was dropped. The expression cannot be changed afterwards, exactly as keywords cannot: the seen-set is keyed to the search, so a new expression would inherit the posts the old one rejected. Create a new search and untrack this one.
creditCap is the one limit you set. A search that captures people has exactly one: its maximum daily credit spend. A credit is charged per new person captured for this source, not per post or repeat engagement. A post with 300 distinct first-time engagers can cost 300 credits, while repeat engagers cost nothing again. A run collects until it has spent creditCap, until the provider has no new posts, or until the walk's safety bound — 20 provider pages per term, and at most 2,000 posts scanned in one run — is reached, never because a post count was reached; each run sweeps only posts it has not captured before, so a post captured once is never re-swept and later runs return new people rather than recharging for the same ones.
Why a run stopped is recorded per search as credits (reached creditCap), post_limit (a posts-only search bought its postsPerSync posts for the day), exhausted (the provider walk ended without a capture cap), error (the sweep itself failed) or ai_error (your own AI credential failed). Read it from GET /api/v1/sources on the source's lastRun: stoppedBy, at, postsScanned, postsKept, postsHarvested, providerRowsDropped, providerPageLimitReached. providerRowsDropped counts provider result rows the worker skipped because they lacked a capturable activity URN; it is a count of rows, not necessarily unique posts. A measured 0 is returned as 0, while older or unmeasured runs omit the field. A true providerPageLimitReached qualifies exhausted: at least one term reached the 20-page safety bound, or the run reached its 2,000-post scan ceiling, so later pages may still hold capturable posts. False is a measured non-page-limit ending and older runs omit it. False does not prove every later page was empty: under relevance ordering, the deliberate all-seen-page guard can stop a repeat walk even though a later page might contain an unseen post. postsScanned→postsKept is the AI filter's before/after; postsHarvested is how far the run reached — posts it actually captured before stopping, which can be far below postsKept when a cap stops it early. credits and post_limit mean a cap ended the run; with exhausted, providerPageLimitReached tells whether the 20-page bound fired; it is not a general proof of exhaustion.
What postsScanned counts is only posts this run had NOT SEEN BEFORE — a search remembers everything it has already swept and never re-captures it — so the first run reads everything it can reach and every run after reports only what appeared since. An established search showing postsScanned: 1 beside exhausted found one new post, and lastRun.reason says how many matches were already captured (“1 new post; 99 further matches were already captured by earlier runs”) — the search's Search settings on the keywords page show the same sentence. That is a healthy steady-state run. A small exhausted with nothing already swept is a different thing and a real shortfall: the provider ran out of results after only a few posts. The keywords page says so in that search's Search settings.
A short run is not caused by the number of words in its term. Corrected 2026-09-24: seven brand-new searches, including single words and two-word phrases, were run under the same 25-post limit searches then had, a one-credit cap, a past-week window and no filters. All seven scanned and kept 25 posts before stopping on credits. A separate new single-word search scanned 179 posts under a 250-post limit before reaching its credit cap. Those measurements overturn the earlier advice based on word count. The keyword goes to the provider verbatim — no quoting, splitting or phrase handling — and no part of the request varies with word count. A mature search may instead find few new posts because it remembers posts captured on earlier runs. The worker records what happens after the provider responds: it skips rows that lack a capturable activity URN, keeps walking after a nonterminal all-skipped page, and reports the total as providerRowsDropped. A page the provider marks terminal still ends the walk without buying nonexistent pages, and providerPageLimitReached says whether the 20-page safety bound prevented a proof of exhaustion. A positive value establishes that upstream rows were unusable; 0 means no provider row was skipped. The worker never converts a ugcPost or share identifier into an activity URN, and does not widen the term to manufacture a result. No setting limits how many posts a run considers any more (the per-run post limit was retired on 30 September 2026): a run reads up to 2,000 posts, and its credit cap may still stop capture first. Widening the date window is useful only when more time is relevant. The dashboard keyword row estimates provider matches from the first page of each searched term; it can drift or count a shared post twice, so it is not a promise of capturable posts. It also says when every term caught up with posts this search already swept. The source-list API exposes the same measured estimate as lastRun.postsAvailable and the catch-up signal as lastRun.caughtUp; older runs omit both rather than inventing values. For a historical run that predates providerRowsDropped, the precise cause remains unmeasured.
Editing a search changes its next run, never the one already running. A sweep reads the search's settings once, when it starts, and holds them for the whole run — so budgets changed while a sweep is in flight do not re-bind it, and a run that began on the old caps finishes on them. An edit made before the sweep starts does bind that run, including one made after its job was queued: the worker reads the settings when it picks the job up, not when the job was created. GET /api/v1/sources returns both halves for a keyword source — config (creditCap, captureMode, maxEngagementsPerPost) is what the next run will use, and lastRun.config is the snapshot of what the last run was actually bound by. Read a run's leadsWritten against that snapshot, not against the current cap: 21 leads beside a stored cap of 5 is an overrun only if the snapshot says 5 too. It is omitted, never filled in from today's settings, for runs that predate it.
On a trial a team may hold at most 2 keyword searches, and each captures at most 250 leads, limits separate from those on profiles and posts (see Free trial). In the dashboard a keyword search's leads appear on the keyword leads page rather than the main leads page; over the API they are returned by GET /api/v1/leads like any other source's, and ?sourceKind=keyword narrows the list to exactly what that page shows.
A search created with a provider and prompt but no stored key will run, stop at the filter, and report that it could not use your key — it does not fall back to capturing every post unfiltered, because that would spend credits on exactly the posts you asked to exclude.
Creating a source you already have returns that source, not a second one. Every source is identified per team — a profile and a company page by their handle, a post by its URN, a keyword search by its joined terms — and creating one twice is a normal thing to do, not an error. POST /api/v1/keyword/track with keywords you already have (whether that search is active or one you deleted) answers 200 with resumed: true instead of 201, and seenPosts says how many posts it has already swept and will therefore skip. Settings you send are applied to that search; settings you omit are left as they are — so a request carrying only keywords gives it back unchanged rather than resetting its name, its AI filter and its caps to the defaults, and name and aiProvider in the response are the search's current values rather than an echo of your request. POST /api/v1/post/track does the same for a post it already tracks, returning the existing source unchanged — but with 201 either way and nothing in the body marking it as a duplicate, so re-read GET /api/v1/sources if you need to know which happened.
Two consequences worth knowing before you repeat a create. Re-creating a source you had untracked tracks it again and queues a capture within seconds, which charges — if you did not mean to bring it back, untrack it again. And because an identifier is unique per team across all four kinds, keywords already held by a profile, a company page or a tracked post come back as 409 with code identifier_in_use naming the source that holds them; nothing is created, and different terms are the fix.
Lead: a person captured from engaging (liking or commenting) with a tracked source's post — a profile's, or a tracked post itself. Leads are the engagers you discover; the source is not a lead.
Grain — counts mean engagements, not people. GET /api/v1/leads returns one row per engagement: each like or comment is its own row (keeping its postUrl / commentText), so someone who engaged five times appears five times. GET /api/v1/engagers is the per-person companion — it aggregates those rows to one per person with an engagementCount. Counting rows (or reading total) from /leads therefore counts engagements, not unique people; use /engagers when you mean people.
Identity — a null linkedinUsername is not a missing lead
Some engagers have no public vanity handle — LinkedIn serves only their member URN, e.g. ACoAAB1_DY0B-SeJU30N…. Capture keys their lead on that URN, but /api/v1/leads returns enriched leads only and enrichment resolves the public handle from the URN first — so by the time a lead is visible it almost always carries a real handle and an ordinary vanity URL. Of the 3,265 enriched leads captured since URN-keyed capture shipped, none kept the URN.
So a null linkedinUsername does not mean “this engager had no handle” — it means enrichment ran and could not resolve one. That is 6,651 leads, about 2.4% of enriched leads, all captured before 8 September 2026 and concentrated in 71 sources (one of them 70% URN-keyed). On those rows linkedinUrl is built from the URN, reads linkedin.com/in/ACoAAB…, and does not resolve as a public profile.
- Key on
linkedinUrnwhenlinkedinUsernameis null — CRM matching or de-duplication keyed on the vanity URL will silently miss these records. - A null handle means “no public handle known”, not “no identity”. Don't discard the row, and don't render the URN as a handle in a UI — it is an opaque id, not a username.
/engagerskeeps whole counts but has nolinkedinUrnfield at all: it merges a person's handle and member-URN captures into one row (grouping onlinkedinUrn), soengagementCountand the ranking are right, and it returns the stored username verbatim — preferring a handle from any row of that person. Today that always finds one: 4,444 of 4,445 URN-keyed people have a handled row, and the single exception is unenriched, which/engagersexcludes. It will not stay that way — those twins are an artefact of the old capture writing one person under both spellings, so a newly captured lead whose handle enrichment cannot resolve will be reported under itsACoAA…URN with no second field beside it. Thelead.detectedwebhook has the same raw shape.
These leads are captured. Until 8 September 2026 they were dropped at capture instead, which lost every reactor on posts whose engagers are handle-less as a population. 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.
Profile/company enrichment has two modes:
- With
saveTrackedProfile: true(CLI--save-tracked-profile) you create a tracked source (or bring back one you untracked) and queue its first sync. This flag is the mode selector for operating on a source, so re-enriching a tracked profile needs it each time. Re-tracking a source that has already synced does not re-sync it: the settings you send apply from its next scheduled run, no sync is queued, and the response says so withsyncId: nullandsyncNotQueuedReason. To sync it now, sendPOST /api/v1/sources/{id}/syncwith its source id (MCPsync_source, CLIsource-sync); it is charged like any sync. - Without the flag, enrichment re-enriches an existing captured lead. If the username isn't a lead you get
404 No lead found. Pass the flag to track it as a source instead.
Quickstart
Track and enrich a profile, then poll the job until it finishes. saveTrackedProfile: true matters on a fresh account: it creates the tracked source (and kicks off automatic engagement capture — see How you get leads); without it, enrichment targets an existing captured lead and 404s when there is none yet.
curl -X POST https://app.cornersight.io/api/v1/enrich/profile \
-H "X-API-Key: cs_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "username": "demo-profile", "saveTrackedProfile": true }'
# => { "jobId": "abc123" }curl https://app.cornersight.io/api/v1/jobs/abc123/status \
-H "X-API-Key: cs_your_key_here"
# => { "status": "completed", "result": { ... } }Prefer not to write polling code? The CLI waits for jobs by default.
How you get leads
Leads are the product's core output, and the pipeline that produces them is mostly automatic: capture (engagement → lead) → enrichment (lead → firmographics) → your dashboard and webhooks.
Capture is automatic for tracked sources. Tracking a profile or company (saveTrackedProfile: true) queues a staged background sync: Cornersight fetches the source's recent posts and captures everyone who liked or commented as leads — every post, every page, with no further calls on your side. The same capture then repeats on the daily sync, picking up new engagement as it happens. (A 15-post profile that once required a ~39-call page-by-page sweep is now covered by the single track call.)
Choosing what the first sync collects. By default the first sync of a person profile or company page takes its latest 15 posts, and every later sync checks the 4 newest. Send firstSyncPosts (1–50: the latest N posts) or firstSyncDays (1–90: the posts from the last N days, at most 50) with saveTrackedProfile: true to choose otherwise; the dashboard's Add Profile form calls it First sync. Only the first sync reads them, every post's engagers are charged as usual, and creditCapPerSync still bounds the run. They do not apply to tracked posts or posts-only watches.
It is queued, not instant — and you can watch it. There is no fixed duration: it depends on current API load and how many posts and engagements the source has. Leads appear progressively while it runs, so an empty lead list shortly after tracking does not mean it failed. The jobId from the enrich call covers only the profile enrichment — the capture sync is separate, and the response also returns a syncId when one was queued. Poll GET /api/v1/profile/{username}/sync (or /api/v1/company/{username}/sync) for its stage and progress, and stop when isFinal is true.
Why it ended, what it cost, and how much it holds. capture.stoppedBy is credits (your creditCapPerSync stopped it and there was more), exhausted (it collected everything it found), capture_empty (it walked posts and captured nobody) or provider_limit — the data provider stopped serving a post's reactions with a page or more still declared, so the capture holds materially fewer people than the post declares and no limit will get the rest. capture.coverage puts the provider's declared total beside what the source holds ({ "declared": 3690, "captured": 1147 }). capture.creditsSpent is what the run was charged — the same ledger GET /api/v1/credits/usage totals, so it agrees with lastRun and the usage breakdown (the charge so far while isFinal is false) — and capture.leadRowsCaptured is the lead-row count it used to report. The top-level stoppedBy names the same ending. A keyword search that fails on its own AI model or on a term the provider refused reports errorCode: "ai_error" or "search_term_rejected" — yours to fix, not ours — and an untracked source always reports nextSyncAt: null.
Capture finishing is not the run finishing. Collection writes the engagement records; enrichment then adds firmographics, charging only for a person not previously enriched for that source, and it normally runs on well after collection has ended. The lifecycle covers both, and so does isFinal — a source stays enriching, and isFinal stays false, until nothing is left in enrichment.pending. Read enrichment for the breakdown, and capture when all you need to know is that collection finished:
| enrichment | meaning |
|---|---|
pending | Queued or in flight — work still owed, and still to be billed. Above 0 means the run is not finished. |
completed | Enriched: has firmographics; a repeat person may be free. |
failed | Enrichment attempts exhausted. Never charged — reported, not left blocking the run. |
skipped | Nothing to add (the provider returned nothing, or there is no chargeable plan). Never charged. |
total | Every lead held for this source. The four buckets above add up to it. |
The stages match what the dashboard shows, so the UI and API tell the same story:
| state | meaning |
|---|---|
queued | Accepted, not started yet. |
collecting_posts | Scanning the source's recent posts. |
collecting_engagements | Capturing the people who engaged with them. |
enriching | Adding company & role data to captured leads — including after collection has finished. |
completed | Capture done AND every lead enriched — isFinal is true. |
failed | Stopped; see error. |
paused | Out of enriching credits; resumes automatically. |
curl https://app.cornersight.io/api/v1/profile/demo-profile/sync \
-H "X-API-Key: cs_your_key_here"
# => { "state": "enriching", "isFinal": false,
# "capture": { "state": "completed", "isFinal": true,
# "completedAt": "2026-07-20T18:04:11.480Z" },
# "enrichment": { "total": 228, "pending": 132,
# "completed": 96, "failed": 0, "skipped": 0 },
# "progress": { "postsCollected": 15, "engagementsCaptured": 228,
# "leadsTotal": 228, "leadsEnriched": 96 } }
# Collection is done; 132 leads are still being enriched and billed, so keep polling.Manual pulls are for targeted checks, not collection. POST /api/v1/post/reactions and /api/v1/post/comments return one page (~50 engagers) per call for one post, immediately — page through with the 0-based page param. Use them to inspect a specific post on demand; you do not need to sweep them post-by-post to collect a tracked source's leads. They accept a post you track (one of a source's 15 most recent) and also a post one of your keyword searches harvested or a posts-only watch saw. The call itself charges nothing; a tracked post's engagers are also saved as that source's leads, while a keyword-search or posts-only post is only read — no lead is saved, so nothing is charged later either.
What a captured engager contains. Engagement records carry the person's username, profileUrl, first/last name, free-text headline, profile picture, and the reaction type or comment text. They contain no structured company, job title, or location — that is what enrichment adds.
Comment times. Every comment row carries postedAt (ISO 8601) and postedAtTimestamp (epoch milliseconds), the same fields a post row has. The time is the provider's own when it sends one; otherwise it is the time the comment's own LinkedIn ID encodes (a comment ID carries its creation time: the commentId in urn:li:comment:(activity:<postId>,<commentId>), shifted right by 22 bits, is epoch milliseconds), used only when plausible. That is how comments from our primary data provider, which sends no time, carry one. Both are null only when neither is available. On a lead, postPostedAt is when the post was published and commentPostedAt is when the comment was posted. Neither is ever filled in from the other. Replies are typically a minority of comment rows (22% of the measured rows beyond each result's first 10); filter on isReply rather than assuming a ratio.
Comment rows. Every comment row has the same fields whichever provider served it: id (the comment's URN — dedupe on it), url (opens the comment), text, the two times, isReply, parentCommentUrn, isEdited, isPinned, totalReactions, totalComments (replies), reactionType and an author object whose type is person or company. A company commenting as itself gets its linkedin.com/company/ link and is never captured as a lead. The job result's source says which provider served the page. The full row schema is PostComment in the OpenAPI spec.
Enrichment turns engagers into full leads. Captured leads are enriched automatically in the background — positions, company, industry, company size, country — at one credit per newly enriched person per source (see Credits & billing; capture itself is free). To re-enrich one lead on demand, call POST /api/v1/enrich/profile with the engager's username and saveTrackedProfile omitted or false.
Expected capture depth. How much of a post you capture is set by how deep LinkedIn will page, not by a fixed share of its displayed count. Posts with up to a few hundred reactions capture essentially completely. Very large posts do eventually stop returning pages — across posts of 23,000–86,000 reactions we measure capture plateauing near 3,000 reactors each. A post's displayed reaction counter and the reactor list LinkedIn actually serves do not always agree, so treat the displayed number as an approximate target rather than a quota. If a post captures far below what you expect, that is worth reporting rather than assuming a ceiling — we do not publish a fixed expected shortfall.
Prefer not to poll? Turn on sync events for a tracked source and Cornersight POSTs sync.completed (or sync.failed) to its webhook URL when a capture run finishes, with the same counts the status endpoint reports. See Webhooks for the payload, signature verification and retry schedule.
Reading your leads. Captured and enriched leads are visible in the Cornersight dashboard and delivered to your endpoint as lead.detected webhooks — that is the intended way to get leads out, and it includes historical leads, so a webhook added long after tracking still gives you everything (see Backfill). To pull leads on demand instead, GET /api/v1/leads lists and filters everything already captured — no re-sweep of posts required. It returns enriched leads for your synced profiles (the same set the dashboard shows), newest engagement first, and accepts profileId/username (the tracked source that captured the leads — the person or company page you monitor, not a lead's own profile), engagementType, isIcp, webhookStatus, company/title/country substring filters and since/until for incremental pulls. Page with limit/offset (max 100) and read total and hasMore. Being a GET, it draws on the higher read rate limit.
Raw leads: captured, never enriched. Any tracked source — a person, a company page, a tracked post or a keyword search — can be switched to raw mode with enrichLeads: false (the default is true). Its engagers are still captured exactly as before, but they are never sent for enrichment, so they carry no job title, company, country or ICP score. A raw lead has the person's LinkedIn URL, their URN (null when unknown), their name when capture recorded one (null rather than an id when it did not), the action (Like, Comment or Author), the comment text, the post's URL, URN and date, and when it was captured. The price is the same: one credit per new person per source, charged when the lead is captured, with repeats free. 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, /api/v1/engagers, the dashboard, CSV exports, lead.detected webhooks or integrations. Set it when you create the source (POST /api/v1/enrich/profile or /enrich/company with saveTrackedProfile: true, POST /api/v1/post/track, POST /api/v1/keyword/track) or later with PATCH /api/v1/profile/{username}, /company/{username} or /keyword/{id} (CLI: --no-enrich-leads). It needs no confirmSpend, is set through the API, MCP and CLI only, and applies to leads captured after the change: leads already enriched stay enriched, and raw leads stay raw. GET /api/v1/sources reports enrichLeads per source, and on a raw source's sync status enrichment.completed and progress.leadsEnriched count the leads captured raw.
Leads and engagers carry companyName (also named company),companyUrl (website URL), companyDomain (website hostname),companyLinkedinUrl (LinkedIn page), companyDescription,companyIndustry, companyLocation (headquarters),companyEmployeeCount (reported total), companyStaffRange (LinkedIn size bucket) and companyEnrichedAt when available. The LinkedIn URL is read from the person's current position. 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.
The Agent
The Agent sets your lead sources up for you and runs them within a daily credit limit you choose. It is at the top of the dashboard sidebar: Engagement Agent (the robot head) runs the setup and shows what it is watching, and Agent Leads lists the people it finds who match who you sell to. A new account is offered it on its first visit, alongside the choice to go straight to the product.
Setup, in three steps
- Your website. Your agent reads your homepage and about page and drafts who you sell to and the topics your buyers talk about. This step is free and usually takes under a minute.
- Who you sell to. Check and edit the draft: job titles, countries, industries, company sizes, your topics (up to 20) and your competitors. More topics drafted from your website are offered underneath: click one to add it.
- Your daily limit. Choose how many credits the agent may spend a day (it starts on your monthly allowance spread over 30 days). Then Start my agent. You never pick sources yourself: the agent works out how many people and topics the limit pays for. On a free trial there is no limit to choose: the agent gets 1,000 people checked.
What starting the agent sets up
- One keyword search per topic, run daily over the past week's posts, capturing everyone who engages.
- A one-off Discover search for the most engaged people posting about your first 10 topics this month (1 credit per person found). When it finishes, a few minutes later, every person it found is tracked: everyone engaging with their last 5 posts, then with their new ones. On a free trial the agent watches up to 20 people plus its topics, and checks 1,000 people in all; its people and topics never use the trial's own profile or keyword slots.
- The daily limit is split between them: half to the topics, half to people at 25 credits a day each (up to 60 people), and anything the people can't use goes back to the topics. Each source gets its share as its own cap, so together they never spend more than the limit in a day. One credit is one person checked, whether or not they match.
- They are kept apart from the sources you add yourself: Profile & Post Engagement, Keyword Engagement and Discover list only yours. To see what the agent monitors, open Watching on the Agent page: click the people or the topics for the full list, with how many leads each has found out of the people checked. Set up again (the … menu on the Agent page) stops everything the agent watches and starts afresh; leads already found stay.
- Several agents per team: Add another website (the same … menu) starts a second agent with its own profile, daily limit, sources and leads, for a team selling more than one product. The website under the agent's name switches between them. A free trial has one agent.
- The first run: until every person and topic has been checked once and their details have loaded, the Agent page and Agent Leads show the run's progress instead of leads, then every lead it found at once. A day after starting, leads show whatever the run's state.
Who shows on Agent Leads
Agent Leads lists the people whose job title is one of yours, one row per person, ranked by how well they fit and how strongly they engaged. The ICP % runs from 40% to 100%: job title 40, country 30, industry 15 and company size 15. A part that enrichment couldn't fill earns half its points, and parts you left empty are not counted, so a title match on its own is always at least 40%. Matching ignores capitalisation and accents and uses whole words, so “Founder” matches “Co-Founder & CEO” and “India” never matches “Indiana”. Hover the % to see what matched, what missed and what was unknown.
The Signal is Extra Strong, Strong, Medium or Weak, from how they engaged. Anyone who engaged twice or more is Extra Strong, and a 100% ICP match who engaged with a post about your topics is at least Strong. Otherwise a comment counts more than a like, and a post that is about your topics adds to it. A comment on a lead-magnet post (“comment GUIDE and I'll send it”) is Strong. Every engagement is Medium or above, except one with a hiring post or someone's personal news, which is Weak. Filter by ICP % and signal above the table. People are listed once their details have been enriched, editing who you sell to re-ranks the list straight away, and the page shows how many people were checked in all.
Each lead shows their job title, company and country, and their strongest engagement, described by what the post was about: “Liked a post about cold email”, “Commented on a post about AI SDR”. When a post by someone the agent watches names none of your topics, it says why that person is watched instead: “Liked a post by a cold email creator”. Someone who engaged more than once shows how many times; hover to see every engagement, who posted it and any comment. Company Details adds the company's LinkedIn page, domain, description, location, industry and size, as on the other leads tables. Export CSV includes all of it.
Sending Agent Leads to a webhook
Add webhook on Agent Leads sets one URL for your agent and which leads go to it, using the same filters as the table (ICP %, signal, job title, country and the rest). Turn on Push new leads moving forward to send each new lead that passes them as it arrives, and Push historic leads to send the past ones that pass them once, when you save. Each lead is one signed POST with the same fields as lead.detected (for the person's newest engagement, without the ICP % or signal), delivered and retried like your other webhooks. A lead already sent is never sent again, and one that failed is tried again. Only Agent Leads are sent: people outside your job titles never are. A keyword search the agent reuses from your own searches keeps the webhook you set on it.
From the API, MCP or CLI
The same agent can be run without the dashboard. Set its profile and topics with PUT /api/v1/agent (MCP set_agent_profile, CLI agent-set); website drafting stays on the dashboard, so callers set the profile themselves. Start it with POST /api/v1/agent/start (start_agent, agent-start): the first call is refused with the daily figure, and it starts once confirmSpend is sent, because it then spends every day until stopped. The people its Discover search finds are added by Cornersight when the search finishes, whether or not anyone has the Agent page open. Read it with GET /api/v1/agent, stop it with POST /api/v1/agent/stop (leads are kept) and list Agent Leads with GET /api/v1/agent/leads (list_agent_leads, agent-leads), built by the same code as this page: one row per person, the ICP % and signal described above, the same ranking, an optional filters as the Filters button, and firstRun saying whether the first run is over.
Several agents per team: every call takes an optional agentId, and GET /api/v1/agent lists them all in agents; PUT with create: true adds another (Add another website). The webhook is part of the same PUT: { "webhook": { "url": "https://…", "autoSend": true, "filters": [{ "column": "icp", "operator": "at_least", "value": "70" }] } } sends each new lead that passes the filters (push new leads moving forward), and POST /api/v1/agent/push (push_agent_leads, agent-push) sends the past ones (push historic leads). On a free trial the start needs no limit: the agent checks 1,000 people in all, watching up to 20 people, on its own allowance.
Untracking a source (soft delete)
To stop tracking a source, call DELETE /api/v1/profile/{username} for a person or DELETE /api/v1/company/{username} for a company page (CLI untrack-profile / untrack-company; MCP untrack_profile / untrack_company). It is synchronous — there is no jobId and no confirmation step.
It hides more than the source, and erases nothing. Untracking is a soft delete: the row is deactivated (status: "inactive") and every lead, post and engagement it captured stays in the database. What changes is access. A deleted person, company page or post is withdrawn from every read surface at once and by one rule — GET /api/v1/leads, GET /api/v1/engagers, the dashboard leads table, the stat cards and the CSV export all drop it from the all-sources view and answer 404 when it is named — and it stops being actionable: it cannot be pushed, and its webhook and ICP config can no longer be read or written. The read half is re-openable and the actionable half is not: ?includeInactive=true on /api/v1/leads and /api/v1/engagers returns the leads that source kept and makes its id resolve, while the push and the /webhook and /icp routes answer 404 whatever you pass. That opt-in is on the API only — the dashboard has no such switch, so what it shows does not change.
Four different things, kept apart. Stopping monitoring is what the call does on every kind. Hiding data is what it additionally does to a person, a company page or a post. Retaining records is unconditional — nothing is erased on any kind, and erasure is a support request rather than an API call. Reactivating is how the hiding is undone: re-tracking the same username revives that source rather than creating a second one, and its leads are served again. A 404 after untracking means the source is out of scope for that read; it is never evidence that anything was erased.
Keyword searches are the exception, and it belongs to the kind rather than to any one surface. Stopping a search (DELETE /api/v1/keyword/{id}) stops the sweep and the daily charge only: its leads stay readable and pushable everywhere leads are read. GET /api/v1/sources?includeInactive=true enumerates what you used to track, each row carrying status: "inactive". For a deleted person, company page or post that is all that endpoint does — it says the source existed. Serving its leads is what the same parameter on GET /api/v1/leads and GET /api/v1/engagersdoes, and those two are the only surfaces that reopen the read: the dashboard has no such switch, and the CLI's leads-list and engagers-list do not carry the flag yet.
It stops work that is already running, and that is where the money is. A sweep in flight is abandoned: the run asks whether its source is still tracked at every checkpoint it passes — before each provider call, before each post is harvested, before each chunk of lead rows — and 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 the stop within moments rather than at the instant the call returns. If the status read itself fails, the run deliberately carries on to the next checkpoint — the control channel fails open, because abandoning a paying customer's sweep over one timed-out query is the worse error. Leads it had already written are kept, and the run is finalised completed with stoppedBy: "untracked" on GET /api/v1/sources/{id}/sync — not on lastRun.stoppedBy, which is a closed enum describing how a keyword sweep ended.
Queued enrichment is written off, and is not charged. Enrichment lands minutes to hours after capture, so the backlog is the part of an untracked source that would otherwise go on billing — about 2,000 credits on the report that prompted this. Every lead of the source still pending or processing is marked skipped with the reason source_untracked and costs no enriching credits. Re-tracking the 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.
Credits are not refunded. Enrichment credits already spent on a source's leads stay spent, and your recorded usage does not go down when you untrack it. This is deliberate: usage is an append-only ledger, so deleting a profile can never rewrite a closed billing period. Untracking frees the tracked-profile slot, not the credits.
Two responses are worth handling explicitly: a source your team does not track returns 404 (a profile tracked by another team reads the same way, so its existence is never disclosed), and a trial profile returns 403 — trial profiles cannot be deleted, so the trial profile cap cannot be reset by deleting and re-adding. Subscribe to a paid plan to manage profiles freely.
Discover Influencers
Discover Influencers is a one-off search for the people who post about a topic and get engagement on it. It is not a recurring search, and it finds the authors rather than the people who react (for those, use 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. In the left rail, Discover Influencers starts a search and Influencer Leads holds the results.
- Topic. A plain list of 1–10 keywords, and a post that matches any of them counts. There is no AND or NOT on Discover. Each keyword is a separate LinkedIn search. Keywords cannot contain quotation marks or brackets.
- Window and order. Every run looks back one month and sorts by relevance, not newest first, so its posts come from across the whole month. Neither can be changed. Roughly 250–700 posts are read per keyword, depending on what LinkedIn's search returns.
- How engagement is measured. Engagement on a post is likes (every reaction type) plus comments. Reposts and views are not counted. A person's average is the sum over their matching posts divided by the number of those posts; most people have one matching post, so their average is that post. The minimum you set applies to the average. Highest engagement is likes plus comments on the person's best matching post: it is what the Influencer Leads table shows and ranks by, and
highestEngagementin the API. - People only. Posts by company pages are skipped.
- Maximum influencers (1–500). The most people one run adds. When more qualify, the highest averages are added first, and the run says how many more qualified but were cut ("N more people qualified, but the run stopped at its limit"; in the API,
qualifiedis greater thanfound). - Countries (optional, 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 and Northern Ireland are the United Kingdom; US and USA the United States; UAE the United Arab Emirates; Holland the Netherlands; and others); finds a country inside a full location ("South Delhi, Delhi, India" is India); and matches whole words only, so "India" never matches "Indiana". Bare "America" and "Korea" are not short forms. Profiles are looked up before anyone is added, best average first, and at most min(500, max(100, 5 × maximum influencers)) people are looked up per run. Someone with no country on their profile is not added. The lookups cost you nothing.
- Credits. 1 credit per influencer added, charged once. Reading posts and looking up countries are free. Before a run starts you confirm up to maximum influencers credits; over the API that is
confirmSpend. - Lifecycle. A run is Running, then Done (shown as No results when it found nobody) or Failed. Most runs finish within a few minutes. Each influencer has a name, LinkedIn URL, job title, company and country (filled in by enrichment shortly after capture, and shown as Enriching… until then), highest engagement, average engagement, post count, and the top post's link and text, and each run keeps its topic. Deleting a run is a soft delete: the run and its influencers leave the lists, but the Author leads it already added stay in your leads and no credits are refunded. From Influencer Leads, Track adds a person as a tracked profile.
In the dashboard. Open Discover Influencers and fill in four steps: 1 Topic (add keywords), 2 Engagement (Minimum average engagement), 3 Location (optional countries) and 4 Results (Maximum influencers), then click Find influencers. Your Searches lists every run with its state, View Leads (Influencer Leads for that search) and Delete; tick several rows to delete them together.
Over the API, MCP and CLI. POST /api/v1/discover starts a run, GET /api/v1/discover lists them, GET /api/v1/discover/{id} reads one with its influencers, and DELETE /api/v1/discover/{id} deletes one. The MCP tools are discover_influencers, list_discover_runs, get_discover_run and delete_discover_run; the CLI commands (4.11.0 and later) are discover, discover-list, discover-get and discover-delete. A create without "confirmSpend": true is refused with 409 spend_confirmation_required and estimatedCredits, and creates nothing; the CLI refuses locally without --confirm-spend.
curl -X POST https://app.cornersight.io/api/v1/discover \
-H "X-API-Key: cs_<your-key>" -H "Content-Type: application/json" \
-d '{"keywords":["claude code","ai agents"],"minEngagement":50,"maxInfluencers":25,"countries":["UK"]}'
# 409
{ "error": "This run can use up to 25 credits: one for each person who meets the bar, charged once, not daily. Nothing was created. Re-send with \"confirmSpend\": true to start it.",
"code": "spend_confirmation_required", "estimatedCredits": 25 }curl -X POST https://app.cornersight.io/api/v1/discover \
-H "X-API-Key: cs_<your-key>" -H "Content-Type: application/json" \
-d '{"keywords":["claude code","ai agents"],"minEngagement":50,"maxInfluencers":25,"countries":["UK"],"confirmSpend":true}'
# 201
{ "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "keywords": ["claude code", "ai agents"],
"expression": "\"claude code\" OR \"ai agents\"", "minEngagement": 50, "maxInfluencers": 25,
"countries": ["United Kingdom"], "datePosted": "PAST_MONTH", "state": "running",
"candidates": null, "qualified": null, "found": 0, "countryChecked": null, "countryMatched": null,
"createdAt": "2026-10-05T12:00:00.000Z", "finishedAt": null, "stoppedBy": null, "reason": null }curl https://app.cornersight.io/api/v1/discover -H "X-API-Key: cs_<your-key>"
# 200 { "runs": [ { "id": "7c9e6679-...", "state": "done", "candidates": 412, "qualified": 31, "found": 25, ... } ], "total": 1 }
curl https://app.cornersight.io/api/v1/discover/7c9e6679-7425-40de-944b-e07fc1f90ae7 -H "X-API-Key: cs_<your-key>"
# 200
{ "run": { "id": "7c9e6679-...", "state": "done", "candidates": 412, "qualified": 31, "found": 25,
"countryChecked": 125, "countryMatched": 25, ... },
"influencers": [
{ "name": "Jane Doe", "linkedinUrl": "https://www.linkedin.com/in/jane-doe", "avatarUrl": "https://media.licdn.com/...",
"jobTitle": "Founder", "company": "Acme", "country": "United Kingdom",
"highestEngagement": 412, "avgEngagement": 268.5, "postCount": 2, "totalEngagement": 537,
"topPostUrl": "https://www.linkedin.com/feed/update/urn:li:activity:7381234567890123456/",
"topPostText": "We moved our whole release process onto Claude Code..." } ] }curl -X DELETE https://app.cornersight.io/api/v1/discover/7c9e6679-7425-40de-944b-e07fc1f90ae7 -H "X-API-Key: cs_<your-key>"
# 200 { "ok": true, "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "deleted": true }cornersight discover --api-key cs_<your-key> --keywords '["claude code","ai agents"]' \
--min-engagement 50 --max-influencers 25 --countries '["UK"]'
# exit 1, nothing sent: "This run can use up to 25 credits, once, not daily. --confirm-spend was not given, ..."
cornersight discover --api-key cs_<your-key> --keywords '["claude code","ai agents"]' \
--min-engagement 50 --max-influencers 25 --countries '["UK"]' --confirm-spend
# { "ok": true, "data": { "id": "7c9e6679-...", "state": "running", ... } }
cornersight discover-list --api-key cs_<your-key>
cornersight discover-get --api-key cs_<your-key> --id 7c9e6679-7425-40de-944b-e07fc1f90ae7
cornersight discover-delete --api-key cs_<your-key> --id 7c9e6679-7425-40de-944b-e07fc1f90ae7Over MCP, an agent calls discover_influencers without confirmSpend first, tells you the quoted credits, and repeats the call with confirmSpend: true only once you agree. See MCP: Discover Influencers and the API reference.
HeyReach
HeyReach runs LinkedIn outreach. Connect it once on the Integrations page (the plug in the sidebar) with a HeyReach API key, and leads go straight into a HeyReach campaign, by hand or automatically. The card shows who connected it and when, how many campaigns can take leads, and a Test connection button.
- By hand. Select rows on Leads, Agent Leads or Influencer Leads (or Select all matching, which follows your filters) and choose Send to HeyReach.
- Automatically. On Agent Leads, the HeyReach button; on any other table, Send to HeyReach in the + menu. Choose a campaign, the same filters as the table, and Push new leads moving forward (each new lead that passes, once enriched) and Push historic leads (the ones already found, once).
- The rules. Only enriched leads are sent, and a lead with no LinkedIn profile URL is skipped. A person is never sent twice to the same campaign: anyone already sent is counted as already in campaign. A paused campaign is never resumed, and a finished one is never restarted. Sent leads carry a small HeyReach badge with the campaign on hover.
- What HeyReach receives. Name, job title, company, country, LinkedIn profile, work email when found and the headline, plus custom fields your HeyReach messages can use:
icp_percentandsignal(Agent Leads),engaged_post_url,comment_textandsource_name. - When something stops it. If HeyReach rejects the key, the card shows Reconnect and auto-push pauses until you reconnect. If a campaign can no longer take leads, auto-push to it pauses until you choose another. Either way a notice appears in the app; nothing is emailed.
The same works over the API, MCP and the CLI. Free trial teams can use HeyReach too; what they can send is bounded by the trial's own lead limits.
Webhooks
Webhooks are how data leaves Cornersight without polling. Both events are signed with the same scheme, but they differ in retry behaviour — which is the distinction that matters when you build a receiver:
| event | fires | signed | auto-retried |
|---|---|---|---|
lead.detected | once per enriched lead | Yes — HMAC-SHA256 | 3 attempts within seconds, then re-push |
sync.completed / sync.failed | once per capture run | Yes — HMAC-SHA256 | Yes — 6 attempts, backoff |
spend.cap_reached | once per source per UTC day, to that source | Yes — HMAC-SHA256 | Yes — 6 attempts, backoff |
spend.ceiling_reached | once per team per UTC day, to every keyword source with sync events on | Yes — HMAC-SHA256 | Yes — 6 attempts, backoff |
Configuration
Webhooks are configured per tracked source, either in the dashboard (Integrations) or over the API: GET and PUT /api/v1/profile/{username}/webhook (and the /company/ equivalent), /api/v1/keyword/{id}/webhook for a keyword search, and /api/v1/sources/{id}/webhook for any source by its id — the only one a tracked post has. The PUT is a partial update — send only the fields you want to change. Each source has a single webhook URL, plus an ICP-only toggle that delivers only leads matching that source's ICP filter. A separate auto-send toggle (on by default) controls whether future leads are queued automatically; with it off, leads are delivered only when you push them explicitly.
The URL must be a public http(s) endpoint. Non-public hosts — localhost, private ranges, link-local, .internal and .local — are rejected when you save and blocked again at delivery time, so a tunnel URL is required for local testing.
lead.detected
One POST per lead — never a batch or an array. A lead is delivered only once it is fully enriched, so the firmographic fields below are already populated on arrival; you never receive a bare engager and have to enrich it yourself. Individual fields can still be null where enrichment found nothing.
{
"event": "lead.detected",
"timestamp": "2026-01-15T09:30:00.000Z",
"data": {
"leadId": "ld_8f3c2a1b9d4e",
"engagementType": "Comment", // "Like", "Comment" or "Author"
"linkedinUsername": "jane-cooper",
"linkedinUrl": "https://www.linkedin.com/in/jane-cooper",
"linkedinUrn": "ACoAAB1a2b3c4d5e6f7g8h9i0", // the stored member URN; null when none was captured
"avatarUrl": "https://media.licdn.com/dms/image/…",
"name": "Jane Cooper",
"jobTitle": "VP of Sales",
"company": "Acme Corp",
"companyName": "Acme Corp", // same value as company
"companyDomain": "acme.com",
"companyUrl": "https://acme.com",
"companyLinkedinUrl": "https://www.linkedin.com/company/acme/",
"companyIndustry": "Software",
"companyEmployeeCount": 387,
"companyStaffRange": "51-200",
"companyDescription": "A software company",
"companyLocation": "San Francisco, California, United States",
"country": "United States",
"commentText": "Love this — exactly what we needed.", // null for Likes
"commentPostedAt": "2026-01-15T08:12:00.000Z", // the comment's own time; null for Likes
"postUrl": "https://www.linkedin.com/feed/update/urn:li:activity:73000…",
"postText": "How we 3x'd outbound reply rates in 90 days.",
"postPostedAt": "2026-01-14T16:45:00.000Z", // the post's publish time, not the comment's
"trackedProfile": "your-tracked-profile",
"isIcp": true
}
}Every field except leadId, engagementType, postUrl, trackedProfile and isIcp is nullable. commentText is null for Likes and Authors, and postText is null for posts without text. engagementType: "Author" is the person who wrote a post a keyword search kept, sent when that search captures post authors. linkedinUrn is the stored member URN and avatarUrl the profile photo; both are null when none was captured. postPostedAt is when the post was published, the same value GET /api/v1/leads returns.
companyUrl, companyLinkedinUrl, companyIndustry, companyEmployeeCount and companyStaffRange 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. If the provider could not answer for a company yet, those fields arrive null and companyEnrichedAt on GET /api/v1/leads stays null for that lead until the company is resolved, which tells the two cases apart.
When it fires
A lead fires the moment it reaches enriched state — from the initial capture sync, from the daily sync as new engagement arrives, or from a background poller that sweeps for enriched-but-undelivered leads. That poller is the reason delivery is reliable: if the sync that enriched a lead crashed or was paused for credits before it could deliver, the lead is still picked up and sent. Delivery is therefore not tied to sync completion — leads arrive progressively while a sync is still running.
Delivery, ordering & retries
Leads are sent oldest first (by detection time), paced ~100ms apart, in batches of 100 and up to 1,000 per source per sweep; anything left over goes out on the next sweep. Every source with queued leads gets a time slice on every sweep, so one slow endpoint or large backlog cannot hold up anyone else's deliveries: the first attempt for a newly queued lead — an explicit push included — normally lands within about a minute. Each POST has a 10-second timeout. Order is best-effort, not guaranteed — do not rely on arrival sequence.
A transport error, 408, 429 or 5xx is retried within the same sweep — 3 attempts in all, about 1 second and then 4 seconds apart. A 4xx or a redirect is a rejection and is not retried. After the third attempt the lead is marked failed and is not retried again automatically; this is the single biggest difference from sync events, which keep retrying for hours. Return 2xx as soon as you have durably accepted the payload, and queue your own processing behind it — if your endpoint keeps returning 500, that lead will sit undelivered until you re-push it (below). Failures surface per lead in the dashboard with the error.
Delivery is at-least-once: if the worker restarts between sending a POST and recording the result, that lead is re-sent. Make your handler idempotent by treating leadId as the deduplication key.
Verifying deliveries
Every delivery is signed — lead.detected, the sync events, and the dashboard's test send (the Test button in the Integrations panel — on the Leads page, open it from the table's “+” menu → Webhook, or the Webhook column → Edit webhook) all use the same scheme and the same per-team secret, so one verifier covers everything. Each request carries X-Cornersight-Signature: sha256=<hex>, X-Cornersight-Timestamp, X-Cornersight-Event and X-Cornersight-Delivery (unique per attempt; deduplicate on data.leadId, not this).
The signature is an HMAC-SHA256 of {timestamp}.{raw body} using your team's webhook secret — reveal it (it starts whsec_) in the Integrations panel next to the Test button, and pass it as secret below. Verify it against the raw body before parsing — re-serializing the JSON can reorder keys and will fail verification — and reject timestamps more than five minutes from your clock to prevent replay. The timestamp is inside the signed material, so a captured payload cannot be replayed under a fresh header.
X-Cornersight-Timestamp is the raw Unix epoch time in seconds, not milliseconds or ISO text; for example, X-Cornersight-Timestamp: 1700000000. Use the header's exact text when building the signed string — do not parse and reformat it. Reject timestamps more than five minutes from your clock.
The test send in the dashboard is signed with the same secret, so — with that secret copied from the Integrations panel — you can confirm your verification code works before any real leads flow.
{
"event": "sync.completed",
"timestamp": "2026-07-20T18:04:11.512Z",
"data": {
"username": "demo-profile",
"profileType": "person",
"syncId": "3f7e…",
"state": "completed",
"isFinal": false,
"completedAt": "2026-07-20T18:04:11.480Z",
"progress": { "postsCollected": 15, "engagementsCaptured": 228,
"leadsTotal": 228, "leadsEnriched": 96 },
"enrichment": { "total": 228, "pending": 132,
"completed": 96, "failed": 0, "skipped": 0 },
"error": null,
"stoppedBy": "credits", // keyword runs only; absent on a person/company sync
"reason": "stopped at this search's credit cap (100 of 100)"
}
}Why a run ended is on the event you already receive. A keyword sweep that stopped at a cap is a successful run, so it arrives as sync.completed with error: null — and until now nothing in the payload said a cap had cut it short. stoppedBy is one of budget (more posts existed), credits (the search's own creditCap), exhausted (the provider walk ended without a capture cap; check lastRun.providerPageLimitReached before concluding that no later pages remain), team_cap (the team's daily ceiling) or lead_cap; on sync.failed it is error or ai_error, or null for a run that threw before it reached its loop. Both fields are absent, not null, on a person or company sync, which has no stop condition of this kind.
Read reason on a completion and error on a failure. reason is the run's own sentence, passed through untouched — and it is always null on sync.failed. That is deliberate, not an omission: a failing run's reason is built from the data provider's message and can carry its whole response envelope, while error beside it is the sanitised field. A webhook body is no less public than a GET response, so the untouched string is published only for the endings whose wording is ours — the caps, the budget, the exhausted sweep.
⚠ Two different fields are spelled stoppedBy, and their value sets do not overlap. The one above is how a keyword sweep ended, and it is the same list GET /api/v1/sources reports as lastRun.stoppedBy. The sync status routes — GET /api/v1/sources/{id}/sync and its per-kind siblings — serve a top-level stoppedBy that names how a person, company-page or tracked-post capture ended — the same ending as capture.stoppedBy on that response: credit_cap, exhausted, capture_empty, provider_limit or untracked. The cap is spelled credits in the sweep's list and credit_cap on the route. Do not map one list onto the other.
This event fires when the CAPTURE run ends — which is usually before its leads have been enriched, so state describes the capture run and isFinal tells you 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; enrichment.pending says how much is left. Wait for those lead.detected callbacks, or poll the sync endpoint until its isFinal is true.
Sync events are off by default and, unlike lead.detected, are retried with exponential backoff — roughly 30s, 2m, 8m, 32m, 2h, 8.5h — for transport errors, 429 and 5xx only; a 4xx is treated as a rejection and not retried. They are signed exactly as described above, so the same verifier handles both.
Which sources send them. Tracked profiles, company pages and keyword searches. A keyword sweep is a sync run like any other, so its event carries profileType: "keyword" with the search's terms as username and the same state/isFinal/enrichment fields; turn it on with PUT /api/v1/keyword/{id}/webhook, addressed by source id. Tracked posts do not — a post's webhook is configured with PUT /api/v1/sources/{id}/webhook and its lead.detected honours webhookUrl, icpOnly and autoSend (its history is pushed with POST /api/v1/sources/{id}/push), but no lifecycle event is sent for a post, so syncEvents: true on one is a 400 (not_supported_for_post).
How soon it arrives. The event is written to a durable outbox the moment a run reaches its terminal state, and a poller drains that outbox roughly every 10 seconds — so the first attempt normally lands 10–20 seconds after the run ends. If nothing has arrived a minute later, it was not lost in transit: the sync status endpoint returns lifecycleEvent for the source, which says whether an event was attempted (enabled: true with lastDelivery: null means none was), which run it belongs to, how many attempts it has had, when the next one is due, and what your endpoint answered. A delivery enqueued but not yet tried reads status: "queued" with its queuedAt, and stalled: true once it has waited five minutes without a first attempt. An explicit lead push is reported there too, when it is the newest delivery on the source: event: "lead.detected" with its pushId and the live counts of its leads.
spend.cap_reached: a run that ran out of allowance
A keyword sweep can end because it spent everything it was allowed to, and that is not a failure: sync.completed still fires, with error: null. This event is how you find out it happened. It uses the same opt-in, the same signature and the same outbox as the sync events above, so the same verifier and the same latency apply and nothing new has to be configured.
{
"event": "spend.cap_reached",
"timestamp": "2026-09-13T09:05:00.512Z",
"data": {
"sourceId": "8f1c…", // the source this is ABOUT — key your records on this
"username": "ai recruiting, talent sourcing",
"profileType": "keyword",
"syncId": "3f7e…",
"cap": "team_daily_ceiling", // or "search_credit_cap"
"scope": "team", // or "search"
"capCredits": 500, // the limit that was reached
"creditsSpent": 60, // what THIS run spent
"teamSpentToday": 500, // the team's keyword spend today (UTC), or null
"skipped": false, // true when the run never started
"reachedAt": "2026-09-13T09:05:00.480Z",
"reason": "stopped at this team's daily keyword credit ceiling: …"
}
}Two caps, and cap tells them apart. search_credit_cap is that search's own creditCap — the limit whoever created the search set, on that search. team_daily_ceiling is the team-wide daily ceiling, which is reached because of what the team's other searches spent today, so a search can hit it having spent a fraction of its own cap. When the ceiling binds, one run stops part-way and the day's remaining searches are skipped — those arrive with skipped: true and creditsSpent: 0, and the ceiling resets at midnight UTC.
Once per source per UTC day — you do not have to deduplicate it. A cap that is worth setting is reached on most sweeps, so an event per run would be the same sentence to the same endpoint every day the product behaved correctly. The claim is taken atomically and fails closed: if it cannot be taken, nothing is sent. Five searches reaching their own caps on one day are still five events — five limits, five owners. Only keyword sources emit it: a profile or company sweep is charged per lead enriched, minutes to hours after its capture ends, so it has no per-run spend to report at the moment a run finishes.
The team ceiling is a second event, spend.ceiling_reached, and it goes to everyone. A search reaching its own cap is a fact about that search, so it is delivered to that search's webhook alone. The team's daily ceiling is a fact about the team, and its defining property is that it stops searches that did nothing wrong — whichever run happens to cross the line is an accident of scheduling order, and every other search that day is skipped without reaching a cap of its own. So it fires once per team per UTC day and is 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). Two names rather than one name with a field, because that is what lets you route “a search I own ran out” and “the team ran out” differently.
In every copy of spend.ceiling_reached, sourceId and username name the search whose run TRIPPED the ceiling — never the recipient. It is one fact delivered to many endpoints, not many personalised events: rewriting the source per recipient would tell nineteen subscribers that their own search hit a ceiling it never touched. The recipient already knows who it is; what it does not know is what happened. Read the day's position at any time from GET /api/v1/credits — spentToday, dailyCeiling and dailyCeilingMode — which resets at midnight UTC.
import { createHmac, timingSafeEqual } from "crypto"
function verify(rawBody, headers, secret) {
const ts = headers["x-cornersight-timestamp"]
const sig = String(headers["x-cornersight-signature"] || "").replace(/^sha256=/, "")
// Replay guard: reject anything older than 5 minutes.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
const expected = createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex")
const a = Buffer.from(expected), b = Buffer.from(sig)
return a.length === b.length && timingSafeEqual(a, b)
}secret: fixture_secret_not_for_production
X-Cornersight-Timestamp: 1700000000
raw body: {"event":"sync.completed","timestamp":"2026-01-15T09:30:00.000Z","data":{"state":"completed"}}
X-Cornersight-Signature: sha256=34e2f8c82d3fe32f7351e121bc8fa8143acdfe7f74c9836fc77671e5429c73f5Backfill: historical leads are replayed
Yes — configuring a webhook after tracking still gets you the back catalogue. This is the answer that decides whether you ever need the API workaround, and for most people it means you do not.
Leads captured while no webhook existed are not discarded — they are kept, and can be delivered at any time. Adding a first URL in the dashboard with auto-send on queues that backlog automatically, oldest first, at the pacing above; saving a URL over the API never does. Nothing is re-enriched, so a backfill costs zero enriching credits no matter how large it is.
Over the API, history is sent by an explicit push: POST /api/v1/profile/{username}/push, the /company/ form, POST /api/v1/keyword/{id}/push, or POST /api/v1/sources/{id}/push for any source by its id — the only push a tracked post has (CLI: source-push-leads · MCP: push_source_leads). Scope it to all or icp leads within an optional since/until window, try it with dryRun: true, and make a retry safe with an idempotencyKey. A push charges no credits: it re-queues leads that were already captured and enriched, and it delivers however autoSend is set.
For anything the automatic path will not cover, the dashboard's push action re-queues already-enriched leads on demand, scoped to all leads, ICP-matching leads only, or a hand-picked selection. It re-queues leads that already delivered (a genuine re-send, e.g. after you wipe a CRM table) as well as ones that failed — which is also how you retry a delivery that failed after its automatic attempts. Because re-sends are possible, deduplicate on leadId.
Recover & segment your leads via the API
There is no lead list/query endpoint yet, so this recipe reconstructs your leads from the post and engagement endpoints. Read Backfill first — a webhook replays historical leads already enriched, in one configuration step, for zero credits. This recipe is the fallback for when you cannot accept an inbound webhook at all, or want an ad-hoc slice without wiring an endpoint. It is markedly more work: answering one ICP question for a single profile took roughly 300 calls and 15 minutes, against data Cornersight already holds.
Every call below is asynchronous — you get a jobId and poll GET /api/v1/jobs/{jobId}/status until completed. Reads are rate limited to 120/min, which is the practical ceiling on how fast this runs.
1. List the source's posts. Call POST /api/v1/profile/posts (or /api/v1/company/posts) and page with paginationToken: pass the token from the completed job's result verbatim to get the next, non-overlapping page, and stop when none comes back. Tokens are endpoint-specific and drift as new posts shift the window, so page through in one pass rather than storing them. Each post returns contentType using the same VIDEO, IMAGE, JOB, LIVE_VIDEO, DOCUMENT and COLLABORATIVE_ARTICLE values as the keyword search filter; it is nullwhen the provider gives no matching type. The same field is returned on the pricedGET /api/v1/profile/{username}/posts andGET /api/v1/company/{username}/posts reads.
curl -X POST https://app.cornersight.io/api/v1/profile/posts \
-H "X-API-Key: cs_your_key_here" -H "Content-Type: application/json" \
-d '{"username":"demo-profile"}'
# poll the jobId, then repeat with {"username":"…","paginationToken":"<token>"}
# until the completed result no longer returns one.2. Pull each post's engagers. For every post URN, call POST /api/v1/post/reactions and /api/v1/post/comments (use /api/v1/post/company-comments for company posts), incrementing the zero-based page until a page comes back empty. Each page is ~50 engagers, and this step is what makes the call count explode: two endpoints × every page × every post.
Gotcha: postUrn must belong to a post your team holds — one of a tracked source's 15 most recent, or one of your keyword searches harvested or a posts-only watch saw. These endpoints will not accept an arbitrary LinkedIn URN, so for a profile's posts step 1 is not optional — you cannot skip ahead with URNs from elsewhere.
curl -X POST https://app.cornersight.io/api/v1/post/reactions \
-H "X-API-Key: cs_your_key_here" -H "Content-Type: application/json" \
-d '{"postUrn":"urn:li:activity:7300000000000000001","page":0}'
# page++ until empty, then repeat for /post/comments.3. Dedupe into a lead list. The same person often reacts to several posts and may both like and comment, so collapse on username — it is the only stable identifier across both endpoints. Display names are not unique and profile URLs vary in form.
4. Filter on headline before spending anything. Every captured engager already carries a free-text headline that arrives with capture at no credit cost. Narrow here — matching founder|co-founder|ceo against the headline — before the enrichment step, since enrichment is the only part that costs credits. Headline is free text, not a structured title: it is good enough to shortlist, and imprecise enough that you should confirm in step 5.
5. Enrich the shortlist for firmographics. Structured job title, company, company domain and country only exist after enrichment. Call POST /api/v1/enrich/profile with saveTrackedProfile: false — one credit for a person not previously enriched by this source; repeat engagement rows are free. Then apply the real filter (for example country = United States) against the structured fields rather than the headline.
Gotcha: with saveTrackedProfile: false the job fails if the person is not already a lead on one of your tracked sources. That is correct here — engagers from step 2 are leads — but it means you cannot use this call to enrich arbitrary people. Passing true instead would create a new tracked source and queue a full sync, which is almost certainly not what you want mid-recipe.
curl -X POST https://app.cornersight.io/api/v1/enrich/profile \
-H "X-API-Key: cs_your_key_here" -H "Content-Type: application/json" \
-d '{"username":"jane-cooper","saveTrackedProfile":false}'
# => { "jobId": "…" } — poll for the enriched firmographics.Endpoints
Leads & enrichment
| GET | /api/v1/sources | List tracked sources — people, company pages and posts (their ids drive the leads filter) | sync |
| GET | /api/v1/leads | List & filter your captured leads | sync |
| GET | /api/v1/engagers | Top engagers — leads aggregated by person + count | sync |
| POST | /api/v1/enrich/profile | Enrich a LinkedIn person profile | async |
| POST | /api/v1/enrich/company | Enrich a LinkedIn company page | async |
| POST | /api/v1/profile/posts | Fetch a person's recent posts | async |
| POST | /api/v1/company/posts | Fetch a company page's recent posts | async |
| POST | /api/v1/post/reactions | Fetch reactors of a post | async |
| POST | /api/v1/post/comments | Fetch comments on a person's post | async |
| POST | /api/v1/post/company-comments | Fetch comments on a company post | async |
| GET | /api/v1/jobs/{jobId}/status | Poll a job until completed or failed | poll |
| GET | /api/v1/profile/{username}/sync | Sync progress for a tracked profile | poll |
| GET | /api/v1/company/{username}/sync | Sync progress for a tracked company page | poll |
| DELETE | /api/v1/profile/{username} | Untrack a profile — deactivates it; its leads are kept but stop being served | sync |
| DELETE | /api/v1/company/{username} | Untrack a company page — deactivates it; its leads are kept but stop being served | sync |
Full request/response schemas and examples are in the API reference (OpenAPI: openapi.json).
Errors
Errors return a JSON body { "error": "message" } with a standard status code. Only 429 and 502 are transient (retry after a backoff);4xx and 503 mean the request or setup must change first. A 500 is neither — it's a fault on our side, and on a write some work may already have been applied, so re-read state before retrying rather than firing the same request again.
| 400 | Bad request: missing or invalid parameters. |
| 401 | Missing or invalid API key. |
| 402 | The team has no active enriching credit plan, so it has no allowance at all (code enriching_plan_not_found, returned by GET /api/v1/credits). Put the team on an active plan to clear it. Note this is NOT the “ran out of credits” error — see Credits & billing. |
| 403 | No active subscription or trial expired. |
| 404 | Resource (e.g. job id) not found. |
| 409 | Conflict: the target's state blocks the action (e.g. creating a key past the per-team cap). Change the state, then retry. |
| 429 | Rate limited: back off using Retry-After. See Rate limits. |
| 500 | Unexpected server error. |
| 502 | Upstream provider error. Transient — retry after a short backoff. |
Idempotency & retries
Reads (GET) are always safe to repeat. Writes are not — and they are not guaranteed atomic: a 500 (our fault) or 502 (upstream provider) can arrive after the change already partially applied, so a blind resend can double-apply. Only 429 and 502 are worth retrying at all — 4xx fails again unchanged. Before retrying any write, re-read the resource's state and resend only the part that didn't take.
Most writes are safe to repeat because they either converge to the same state or short-circuit once the work is already done (see the table). The one write that is never safe to blindly repeat is POST /keys, which mints another API key. It carries no idempotency key — guard it on your side: don't resend until you've confirmed the first call's outcome by re-reading, and if you need at-most-once creation, track your own request id.
| Write | On a repeated / retried call | Retry |
|---|---|---|
| PUT /profile|company/{username}/webhook | Merges the same fields; converges. | Safe |
| DELETE /profile/{username} · /company/{username} · /keys/{id} | Returns 404 once the target is already gone — the delete already took. | Safe |
| POST /enrich/profile · /enrich/company | Never double-charges — an already-enriched lead returns creditsCharged: 0 — but each call mints a new job id and re-runs the provider scrape. | Credits safe; job duplicates |
| POST /keys | Mints another API key (until the per-team cap → 409). | Unsafe |
| POST /keyword/track | Returns the search those keywords already identify — 200 with resumed: true, never a second search. Settings you resend apply to it; omitted ones are left alone. | Safe; re-tracks |
| POST /post/track | Returns the existing source unchanged, still 201 — nothing in the body marks it as a duplicate. | Safe; re-tracks |
| POST /post/comments · /post/company-comments · /post/reactions · /profile/posts · /company/posts | New job id re-runs the provider scrape. Lead rows de-dupe and no enriching credit is spent, but you pay a redundant fetch. | Job duplicates |
One caveat on the “Safe” rows. The async job endpoints (enrich/*, post/*, profile/posts, company/posts) create a fresh jobId on every call, so a retry duplicates the job even though it never duplicates lead rows or credits — dedupe on your side if a redundant provider fetch matters.
And one on the two “re-tracks” rows. Repeating either track call never creates a second source and never double-charges for the repeat itself. But if the source had been untracked, repeating the call brings it back: it becomes active again and a capture is queued within seconds, which charges. That is the intended way to resume a source — it is only a surprise when the repeat was a blind retry. Untrack it again to stop it.
Rate limits
Requests are rate limited per team — keyed by API key, and MCP tool calls draw from the same budget — with separate windows for reads and writes, so heavier writes are capped lower than cheap reads. Defaults are generous for normal use and can be raised for your team on request.
| Bucket | Methods | Default limit |
|---|---|---|
| Reads | GET | 120 / minute |
| Writes | POST, PUT, PATCH, DELETE | 60 / minute |
Every /api/v1/* response carries the current window state so you can throttle before hitting the limit:
| X-RateLimit-Limit | Max requests allowed in the current window for this bucket. |
| X-RateLimit-Remaining | Requests remaining in the current window. |
| X-RateLimit-Reset | Epoch seconds when the window resets. |
| Retry-After | On 429 only: seconds to wait before retrying. |
Exceeding a window returns 429 Too Many Requests with body { "error": "…", "code": "rate_limited" }. Wait Retry-After seconds (equivalently, until X-RateLimit-Reset) before retrying — ideally with jitter — rather than retrying immediately.
Free trial
A trial lasts 7 days and needs no card. It is sized in leads, per part of the product; we spend whatever credits it takes to reach them.
| Part | On a trial | Up to |
|---|---|---|
| Engagement Agent | People checked across every source it sets up (up to 20 people watched, plus its topics) | 1,000 people |
| Profiles and company pages | 2, up to 250 leads each | 500 leads |
| Tracked posts | 2, up to 250 leads each | 500 leads |
| Keyword searches | 2, up to 250 leads each | 500 leads |
| Influencer searches | 2, up to 100 people each | 200 people |
- The agent's own people and topics never use the trial's profile or keyword slots, and its searches are not counted against the team's daily keyword ceiling: the agent is bounded by its own allowance.
- A source that reaches its leads stops with
stoppedBy: "lead_cap"; adding a source past the slots is a403withcode: "trial_profile_limit"on the dashboard routes, the API, the MCP and the CLI alike. - Trial sources can't be deleted until you subscribe, so deleting never frees a slot. Leads a source captured count toward what the trial has used.
- What a trial has used is on the trial banner's View usage panel. Limits are shown in the product, not by email.
Credits & billing
Only enrichment spends credits: one credit per newly enriched person per source. A person who engages again with that source can create another lead row without another charge. Failed or empty enrichment attempts are not charged. Everything else (posts, reactions, comments, job polling) costs no credits. A source in raw mode (enrichLeads: false) is charged the same one credit per new person per source when each lead is captured, and is never enriched.
| Operation | Cost |
|---|---|
| Profile / company enrichment — each newly enriched person per source | 1 credit |
| Raw lead (a source with enrichLeads: false) — each new person per source, never enriched | 1 credit |
| Repeat engagement by that person in the same source | Free — never re-charged |
| Failed or empty enrichment attempt | Free |
| Posts, reactions, comments, job status | Free |
When a run's spend is settled. Credits are charged per new person for that source, as enrichment finishes — not in one lump when capture ends — so a run can keep costing credits after collection has finished. A run is done spending, and its downstream processing (lead.detected webhooks and provider integrations) has run, when its source reports enrichment.pending: 0 on GET /api/v1/profile/{username}/sync — the same moment isFinal and leadsReady become true. enrichment.completed counts enriched rows (on a raw source, rows captured raw), which can exceed credits charged when a person engages again; failed and skipped cost nothing.
Bounding what a source may spend. Each person, company page and tracked post takes a creditCapPerSync — the dashboard's Credit limit per sync — which bounds at most N lead rows per sync, every sync for a person, company or post source; its billing is one credit per newly enriched person per source, with no estimate, no confirmation gate and no team ceiling behind it. A keyword search's creditCap is a different field with all three, and the team's dailyCeiling counts keyword spend only — enrichment charges never touch it. See Concepts for how to set and change one.
Credits come from your plan's monthly allowance and reset each billing period. Check your balance with GET /api/v1/credits (balance, used, limit, remaining, plan, reset dates) and audit where credits went with GET /api/v1/credits/usage — a by-source and per-day breakdown (e.g. Public API enrichment vs the automated sync) that reconciles against the balance.
When the allowance runs out, enrichment does not fail with an HTTP error — the request is still accepted and returns a jobId. The job then finishes as failed, and GET /api/v1/jobs/{jobId}/status reports { "status": "failed", "error": "Insufficient enriching credits" }. So always read the job status rather than treating 201 as success. Capture keeps running and nothing is lost — the affected leads stay un-enriched and are picked up automatically once the allowance resets or your plan is upgraded. The separate 402 means something different: the team has no credit plan at all (see Errors).
API reference
REST endpoints, params & examples.
MCP server
Connect ChatGPT & Claude.
CLI
Run jobs from your terminal.