API reference
The Cornersight Public REST API. Authenticate every request with the X-API-Key header. Most endpoints are synchronous and answer in one request — the reads, and the writes that create, update or untrack a source. The enrichment and post/engagement endpoints are the asynchronous ones: they return a jobId you poll on GET /api/v1/jobs/{jobId}/status until the job is completed or failed. That status read is itself synchronous: it reports the job's current state and returns. Each operation below says which it is. This page is generated from the OpenAPI spec.
Lead and engager responses and the lead.detected webhook include company name, website URL and domain, LinkedIn company URL, description, industry, headquarters location, and employee size when available. The website URL (companyUrl) is distinct from its hostname (companyDomain) and LinkedIn page (companyLinkedinUrl). 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. Industry is companyIndustry, and employee size is reported as both companyEmployeeCount and companyStaffRange. 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. Leads and engagers also carry companyEnrichedAt. 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.
A keyword search can capture engagers (the default) or matching posts only. Set mode: "posts_only"and postsPerSync (1–60) on keyword track or update. Confirm the recurring maximum with confirmSpend; without it a new posts-only watch is refused with a 409 quote. Each newly kept post costs one credit, while repeats and rejected posts are free. Read saved text at GET /api/v1/sources/{id}/posts; the kept-posts verdict list does not include text. Both routes carry each keyword post's author (name, url, headline), commentsCount, totalReactionCount, contentType and postedAt as the search reported them when it found the post, so posts can be triaged without buying their engagers; each is null where nothing recorded it.
In engagers mode, captureEngagers (default true) and capturePostAuthors (default false) choose who is captured: the people who liked or commented, the person who wrote each kept post, or both — at least one must be true, and neither applies to posts-only. An author is charged like an engager, one credit per new person with repeats free; a post written by a company page captures no author and is not charged. Author leads carry engagementType: "Author" on leads, the webhook and CSV, and GET /api/v1/leads?engagementType=Author filters to them. With engagers off no reactions or comments are fetched and a run adds at most one new person per kept post, still up to creditCap a day; turning engagers back on for such a search needs confirmSpend. Each run reports lastRun.postAuthorsCaptured and lastRun.companyAuthorsSkipped.
Syncing a source now. Re-tracking a person, company page or post that has already synced does not re-sync it: the response carries syncId: null and syncNotQueuedReason. To sync one now, send POST /api/v1/sources/{id}/sync with its source id. It is charged like any sync of that source, returns the job already in flight (queued: false) rather than queueing a second, and moves the next scheduled sync at least a day out. Keyword searches run on their own schedule and are refused.
Raw mode. Any tracked source can keep its leads raw: send enrichLeads: false (default true) on POST /api/v1/enrich/profile or /enrich/company with saveTrackedProfile: true, POST /api/v1/post/track, POST /api/v1/keyword/track, or later on the profile, company or keyword PATCH. Its engagers are captured and charged exactly as before — one credit per new person per source, repeats free — but never enriched. Read them with GET /api/v1/leads/raw: each row carries the person's LinkedIn URL, URN (null when unknown) and name (null when capture recorded only an id), the action (Like, Comment or Author), the comment text, the post's URL, URN and date, and detectedAt. Raw leads never appear in GET /api/v1/leads, /engagers, the dashboard, exports, webhooks or integrations. The switch needs no confirmSpend and applies to leads captured after it; on a raw source's sync status, enrichment.completed and progress.leadsEnriched count the leads captured raw.
Switching mode. PATCH /api/v1/profile/{username} and /company/{username} also take mode: posts_only with postsPerSync (1–60) turns a source into a posts-only watch (new posts only, one credit per new post, no leads), and engagers turns it back. Only that second switch can raise the cost, so it is refused 409 spend_confirmation_required and changes nothing until the same request is sent with confirmSpend: true. Like every edit there, it binds from the next sync.
First sync. With saveTrackedProfile: true, POST /api/v1/enrich/profile and /enrich/company take firstSyncPosts (1–50, the latest N posts) and firstSyncDays (1–90, the posts from the last N days, at most 50) to choose what the new source's first sync collects; null or absent is the default, the latest 15 posts. Later syncs still check the 4 newest posts.
The Engagement Agent. Set its profile with PUT /api/v1/agent, for example { "topics": ["cold email", "AI SDR"], "titles": ["VP Sales"], "countries": ["United Kingdom"], "dailyCredits": 1000 } (a partial update that starts nothing), then start it with POST /api/v1/agent/start. Without confirmSpend: true the start is refused 409 spend_confirmation_required with estimatedDailyMax, the plan split and the one-off discoverCost, and nothing changes. Started, it runs one keyword search per topic every day (up to 20 topics) and watches the people a one-off Discover run finds, which the worker adds when that run finishes. Half the daily limit goes to the topics; the other half watches people at up to 25 credits a day each. Together they never spend more than the daily limit, and they recur until POST /api/v1/agent/stop. GET /api/v1/agent/leads lists Agent Leads exactly as the dashboard does: one row per person, ranked by ICP % from 40 to 100 (the job title must match; title 40, country 30, industry 15, company size 15; an unknown part earns half) then signal (Extra Strong for engaging twice or more, Strong for a comment on a post about your topics or a lead-magnet post, at least Strong for a 100% ICP match on a post about your topics, Weak only for a hiring or personal post), with checked (everyone captured, which is what the team pays for) beside total, optional filters and firstRun (not done until the first run finishes, never longer than a day). Every call takes agentId for one of several agents; webhook on the PUT with autoSend sends new leads that pass its filters (the dashboard's Push new leads moving forward), and POST /api/v1/agent/push sends past ones. On a free trial the agent has 1,000 people checked in all and watches up to 20 people. Drafting a profile from a website stays on the dashboard.
HeyReach. Once HeyReach is connected on the dashboard's Integrations page (the key never goes over the API), GET /api/v1/integrations/heyreach/campaigns lists the campaigns that can take leads and POST /api/v1/integrations/heyreach/send adds up to 1,000 at a time: { "campaignId": "235", "leadIds": ["…"] }, with agentId to carry each Agent Lead's ICP % and signal, or people for influencers found by Discover. 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 (alreadyInCampaign counts them), and a paused campaign is never resumed. Auto-push is heyreach on PUT /api/v1/agent and PUT /api/v1/sources/{id}/heyreach for any source: { "campaignId", "autoSend", "filters", "pushHistoric" }. It pauses itself when HeyReach rejects the key or the campaign can no longer take leads, and GET /api/v1/integrations/heyreach says which.
Webhook signatures use X-Cornersight-Timestamp as raw Unix epoch seconds (example: 1700000000). Sign the exact header text, a period, and the exact raw request body; reject timestamps more than five minutes from your clock. An offline test fixture and Node verifier are in Webhook security.
Agent
Read the Engagement Agent
/api/v1/agentThe team's Engagement Agent: the profile it targets, its daily credit limit and how that is split (`plan`), whether it is running, its one-off people search and whether that search's people have been added, and every source it set up. `{ "agent": null }` when the team has never set one up. Read-only. `agents` lists every agent the team holds; `agentId` picks one.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
agentId | string <uuid> | no | Which agent: a team may hold several, one per website (GET /api/v1/agent lists them in `agents`). Omit for the team's first agent. |
Responses
200 — The agent, or null.
| Field | Type | Required | Description |
|---|---|---|---|
heyreachHistoric | object | no | Only on PUT with heyreach.pushHistoric: how many past Agent Leads matched and how many were queued for HeyReach. |
└matched | integer | yes | |
└queued | integer | yes | |
agent | object | yes | Null when the team has never set an agent up. |
agents | object[] | yes | Every agent the team holds, one per website. |
└id | string <uuid> | yes | |
└website | string | null | yes | |
└status | "draft" | "active" | yes |
{
"agent": {
"status": "active",
"website": "acme.com",
"profile": {
"summary": "Sales engagement software for B2B teams.",
"titles": [
"VP Sales",
"Head of Growth"
],
"industries": [
"Software Development"
],
"companySizes": [
"51-200",
"201-500"
],
"countries": [
"United Kingdom"
],
"topics": [
"cold email",
"AI SDR"
],
"competitors": [
{
"name": "Outreach",
"domain": "outreach.io"
}
]
},
"dailyCredits": 1000,
"plan": {
"perTopic": 250,
"people": 20,
"total": 1000
},
"launchedAt": "2026-10-06T09:00:00.000Z",
"discoverRun": "8a1c0b2e-4f3d-4c55-9e0a-2b7d1f6c9a10",
"peopleAdded": true,
"sources": [
{
"id": "5c0e6a7b-1d2f-4e3a-8b9c-0d1e2f3a4b5c",
"type": "keyword",
"label": "cold email",
"active": true
},
{
"id": "9f8e7d6c-5b4a-4321-8fed-cba987654321",
"type": "person",
"label": "Jane Doe",
"active": true
}
]
}
}| Status | Meaning | Example error |
|---|---|---|
| 400 | agentId is not a UUID. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 404 | Code `agent_not_found`: no agent with that `agentId`, or the team has no agent yet. Create one with PUT /api/v1/agent. Without `agentId` there is no 404: the answer is `{ agent: null, agents: [] }`. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 502 | The agent could not be read or saved; nothing was changed. Retry shortly. | Could not read your agent; nothing was changed. Retry shortly. |
Create or update the Engagement Agent's profile
/api/v1/agentA PARTIAL update of the website, the profile's parts and the daily limit: only the fields sent change. Creates the agent as a draft when the team has none. Starts nothing and charges nothing: a running agent keeps its sources and caps until POST /api/v1/agent/start runs again. The body is flat: `topics`, `titles` and the rest sit beside `website` and `dailyCredits`, and come back under `agent.profile`. Drafting a profile from the website is a dashboard-only step, so callers set the parts themselves. Also sets the agent's webhook, and `create: true` adds another agent.
Authenticated with the X-API-Key header.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
heyreach | object | no | HeyReach auto-push of Agent Leads: { campaignId, autoSend, filters, pushHistoric }, or null to remove it. Checked before anything is written: the campaign must exist and take leads, and HeyReach must be connected. Only enriched leads with a LinkedIn profile are sent, each person once per campaign; a paused campaign is never resumed. |
website | string | null | no | The team's website, e.g. `acme.com` or `https://www.acme.com`; stored as the bare domain. null clears it. Drafting a profile from the website is a dashboard-only step. |
summary | string | no | One sentence on what the team sells; stored up to 400 characters. |
titles | string[] | no | Job titles the team sells to. At most 15. |
industries | string[] | no | Industries, as LinkedIn names them. At most 15. |
companySizes | "1-10" | "11-50" | "51-200" | "201-500" | "501-1000" | "1001-5000" | "5001-10000" | "10001+"[] | no | LinkedIn's company size ranges only. |
countries | string[] | no | Countries. At most 15. |
topics | string[] | no | What the agent searches for: one daily keyword search per topic. At most 20; quotes and brackets are removed and each is kept to 60 characters. |
competitors | object[] | no | Competitors by name, optionally with a domain. At most 8. |
dailyCredits | integer | no | The daily credit limit. Clamped to 50..20000. Takes effect the next time the agent is started. Ignored on a free trial, which checks 1,000 people in all. |
agentId | string <uuid> | no | Which agent: a team may hold several, one per website (GET /api/v1/agent lists them in `agents`). Omit for the team's first agent. |
create | boolean | no | Add another agent with these settings. Refused 403 `trial_agent_limit` on a free trial. |
webhook | object | no | Where Agent Leads are sent and which (see AgentWebhook), replaced whole: an omitted autoSend is false and omitted filters are none. null removes it. |
Request example
{
"website": "acme.com",
"titles": [
"VP Sales",
"Head of Growth"
],
"industries": [
"Software Development"
],
"companySizes": [
"51-200",
"201-500"
],
"countries": [
"United Kingdom"
],
"topics": [
"cold email",
"AI SDR"
],
"dailyCredits": 1000
}Responses
200 — The saved agent.
| Field | Type | Required | Description |
|---|---|---|---|
heyreachHistoric | object | no | Only on PUT with heyreach.pushHistoric: how many past Agent Leads matched and how many were queued for HeyReach. |
└matched | integer | yes | |
└queued | integer | yes | |
agent | object | yes | Null when the team has never set an agent up. |
agents | object[] | yes | Every agent the team holds, one per website. |
└id | string <uuid> | yes | |
└website | string | null | yes | |
└status | "draft" | "active" | yes |
| Status | Meaning | Example error |
|---|---|---|
| 400 | A field is invalid (named in the message), an unknown field was sent (`unknown_field`), or nothing was sent. The webhook is refused when its url is not a public http(s) URL, when it has a key other than url, autoSend, filters (and the read-only since, which is ignored), or when a filter row's operator or value is not one its column takes. | topics takes at most 20 entries. |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | Code `trial_agent_limit`: a free trial has one agent. | |
| 404 | Code `agent_not_found`: no agent with that `agentId`, or the team has no agent yet. Create one with PUT /api/v1/agent. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 502 | The agent could not be read or saved; nothing was changed. Retry shortly. | Could not read your agent; nothing was changed. Retry shortly. |
| 503 | Code `agent_unavailable`: this server's database does not have the agent yet. | The agent is not available on this server yet. |
Start the Engagement Agent
/api/v1/agent/startThe dashboard's launch, server side. It first STOPS every source the agent set up before (the soft untrack DELETE /api/v1/keyword/{id} and /profile/{username} run, so their leads are kept), then creates one daily keyword search per topic, named `Agent: <topic>`, past week, capturing the people who engage, each capped at `plan.perTopic` (a live search the team already has on a topic is reused and never re-capped), and starts a one-off people search: a Discover run on the first 10 topics for the most engaged people posting about them, capped at `plan.people`. WHEN THAT SEARCH FINISHES, THE WORKER ADDS ITS PEOPLE as watched profiles (25 credits a sync each, a 5-post first sync); `agent.peopleAdded` turns true then. THE AGENT RECURS: every topic search runs daily and every watched person syncs daily until POST /api/v1/agent/stop, so `plan.total` is a standing daily commitment. Billing: one credit per new person any of its sources captures, whether or not they match the profile. Without `"confirmSpend": true` the call is refused 409 `spend_confirmation_required` with the figures, and nothing is stopped or created. Each topic search goes through POST /api/v1/keyword/track's own checks (balance, trial limits); one that is refused is listed in `launch.failed` and the rest start. On a free trial the agent checks 1,000 people in all on its own allowance: its topic searches and people never use the trial's own keyword or profile slots.
Authenticated with the X-API-Key header.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
dailyCredits | integer | no | The daily limit to start with (clamped to 50..20000). Omit to use the one on the profile; one of the two is required. On a free trial there is no limit to choose: the agent checks 1,000 people in all. A value sent here is also saved on the profile. |
confirmSpend | boolean | no | Authorises the recurring daily spend. Without `true` the start is refused 409 `spend_confirmation_required` with the figures, and nothing is stopped or created. |
agentId | string <uuid> | no | Which agent: a team may hold several, one per website (GET /api/v1/agent lists them in `agents`). Omit for the team's first agent. |
Request example
{
"dailyCredits": 1000,
"confirmSpend": true
}Responses
200 — Started: the agent and what this start did.
| Field | Type | Required | Description |
|---|---|---|---|
agent | object | yes | |
└heyreach | object | yes | HeyReach auto-push: the campaign new Agent Leads go to (each with its ICP % and signal as custom fields), or null. Set with `heyreach` on PUT /api/v1/agent. |
└status | "draft" | "active" | yes | `active` once started; `draft` before that and after POST /api/v1/agent/stop. |
└website | string | null | yes | The team's website, a bare domain such as `acme.com`. |
└profile | object | yes | Who the team sells to and what the agent searches for. Agent Leads ranks people against it: the job title must match for a lead to show, and country, industry and company size only lower the ICP % (title 40, country 30, industry 15, company size 15; an unknown part earns half), from 40 to 100. Departments and seniority are not checked. |
└summary | string | yes | One sentence on what the team sells. |
└titles | string[] | yes | Job titles the team sells to. |
└industries | string[] | yes | Industries, as LinkedIn names them. |
└companySizes | "1-10" | "11-50" | "51-200" | "201-500" | "501-1000" | "1001-5000" | "5001-10000" | "10001+"[] | yes | LinkedIn company size ranges. |
└countries | string[] | yes | |
└topics | string[] | yes | What the agent searches LinkedIn posts for: one daily keyword search per topic. The first 10 also find the people to watch. |
└competitors | object[] | yes | |
└name | string | yes | |
└domain | string | no | |
└dailyCredits | integer | null | yes | The daily credit limit, or null until one is set. |
└plan | object | yes | How `dailyCredits` is split across the agent's sources. Null until a limit is set. |
└launchedAt | string | null <date-time> | yes | When the agent was last started. |
└discoverRun | string | null <uuid> | yes | The one-off people search the last start began (a Discover run). |
└peopleAdded | boolean | yes | True once the people that search found are watched. The worker adds them when the search finishes, usually a few minutes after the start. |
└sources | object[] | yes | Every source the agent set up, active or stopped. |
└id | string <uuid> | yes | The source id, as GET /api/v1/sources reports it and the per-source routes take it. |
└type | "person" | "company" | "keyword" | yes | A topic search is `keyword`; a watched person is `person`. |
└label | string | yes | A topic search's topic, or a watched person's name. |
└active | boolean | yes | False once the source is stopped. Its leads are kept and still listed. |
└id | string <uuid> | yes | This agent's id: what every agent route takes as `agentId`. |
└webhook | object | yes | |
└trial | object | yes | On a free trial: the agent's own allowance (1,000 people checked in all, up to 20 people). Null on a paid plan. |
launch | object | yes | |
└plan | object | yes | How the daily limit is split, the figure the dashboard's step 3 shows. Half goes to the topics, shared evenly; the other half watches as many people as it pays for (at most 60, each capped at 25 credits a sync), and what the people cannot use goes back to the topics. Every share is rounded down, so `total` never passes the limit. |
└perTopic | integer | yes | Each topic search's credit cap per daily run. |
└people | integer | yes | How many people the agent watches, each capped at 25 credits a sync. Also the most the one-off people search can charge (one credit per person found). |
└total | integer | yes | The most every source together can spend in one day. It recurs daily until the agent is stopped. |
└dailyCredits | integer | yes | |
└created | string <uuid>[] | yes | Topic searches created (or resumed) by this start. |
└reused | string <uuid>[] | yes | Live searches the team already had on one of the topics, watched as they are and never re-capped. |
└discoverRun | string | null <uuid> | yes | The one-off people search. Null when the plan watches nobody. |
└stopped | string <uuid>[] | yes | Sources from the previous start that were untracked first. |
└notStopped | object[] | yes | Earlier sources that could not be untracked, as in AgentStopReport. |
└failed | object[] | yes | Topics (or the people search) that could not start, with the keyword create's own refusal (balance, trial limit, and so on). The rest started. |
└part | string | yes | The topic, or `people search`. |
└error | string | yes | |
└code | string | no | |
└status | integer | yes |
| Status | Meaning | Example error |
|---|---|---|
| 400 | An invalid field, no topics (`agent_no_topics`), or no daily limit here or on the profile (`agent_daily_credits_required`). | Add at least one topic before starting the agent: PUT /api/v1/agent with "topics". |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | Code `agent_not_started`: nothing could be created, so the agent is left as a draft and `launch.failed` says why each part was refused (for example, out of credits). It comes with the status of the first refusal (400, 402, 403, 409 or 429), or 502 when a create failed outright. Earlier agent sources were already stopped. | |
| 404 | Code `agent_not_found`: the team has no agent yet. Create one with PUT /api/v1/agent. | This team has no agent yet. Create one with PUT /api/v1/agent, giving at least one topic. |
| 409 | Code `spend_confirmation_required`. Nothing was stopped or created. Say `estimatedDailyMax`, that it recurs daily, and `discoverCost` to the person, then re-send with `"confirmSpend": true`. | Starting this agent can spend up to 1000 credits a day, every day until it is stopped: 2 daily topic searches at up to 250 each, and up to 20 watched people at up to 25 each. Finding those people is a one-off of up to 20 credits. Re-send with "confirmSpend": true to start it. |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 502 | The agent could not be read or saved. When the save fails after the searches were created, they are running: GET /api/v1/agent shows the state, and starting again reuses them rather than adding more. |
Stop the Engagement Agent
/api/v1/agent/stopUntracks every active source the agent set up (the same soft untrack the DELETE routes run), so none of them runs or charges again, and sets the agent back to `draft`. The leads they captured are kept and GET /api/v1/agent/leads still serves them. A people search already running finishes, but its people are not added once the agent is stopped. A trial team's own sources cannot be untracked and are listed in `stop.notStopped`. Takes only an optional `agentId`.
Authenticated with the X-API-Key header.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
agentId | string <uuid> | no | Which agent: a team may hold several, one per website (GET /api/v1/agent lists them in `agents`). Omit for the team's first agent. |
Responses
200 — Stopped: the agent and what this call stopped.
| Field | Type | Required | Description |
|---|---|---|---|
agent | object | yes | |
└heyreach | object | yes | HeyReach auto-push: the campaign new Agent Leads go to (each with its ICP % and signal as custom fields), or null. Set with `heyreach` on PUT /api/v1/agent. |
└status | "draft" | "active" | yes | `active` once started; `draft` before that and after POST /api/v1/agent/stop. |
└website | string | null | yes | The team's website, a bare domain such as `acme.com`. |
└profile | object | yes | Who the team sells to and what the agent searches for. Agent Leads ranks people against it: the job title must match for a lead to show, and country, industry and company size only lower the ICP % (title 40, country 30, industry 15, company size 15; an unknown part earns half), from 40 to 100. Departments and seniority are not checked. |
└summary | string | yes | One sentence on what the team sells. |
└titles | string[] | yes | Job titles the team sells to. |
└industries | string[] | yes | Industries, as LinkedIn names them. |
└companySizes | "1-10" | "11-50" | "51-200" | "201-500" | "501-1000" | "1001-5000" | "5001-10000" | "10001+"[] | yes | LinkedIn company size ranges. |
└countries | string[] | yes | |
└topics | string[] | yes | What the agent searches LinkedIn posts for: one daily keyword search per topic. The first 10 also find the people to watch. |
└competitors | object[] | yes | |
└name | string | yes | |
└domain | string | no | |
└dailyCredits | integer | null | yes | The daily credit limit, or null until one is set. |
└plan | object | yes | How `dailyCredits` is split across the agent's sources. Null until a limit is set. |
└launchedAt | string | null <date-time> | yes | When the agent was last started. |
└discoverRun | string | null <uuid> | yes | The one-off people search the last start began (a Discover run). |
└peopleAdded | boolean | yes | True once the people that search found are watched. The worker adds them when the search finishes, usually a few minutes after the start. |
└sources | object[] | yes | Every source the agent set up, active or stopped. |
└id | string <uuid> | yes | The source id, as GET /api/v1/sources reports it and the per-source routes take it. |
└type | "person" | "company" | "keyword" | yes | A topic search is `keyword`; a watched person is `person`. |
└label | string | yes | A topic search's topic, or a watched person's name. |
└active | boolean | yes | False once the source is stopped. Its leads are kept and still listed. |
└id | string <uuid> | yes | This agent's id: what every agent route takes as `agentId`. |
└webhook | object | yes | |
└trial | object | yes | On a free trial: the agent's own allowance (1,000 people checked in all, up to 20 people). Null on a paid plan. |
stop | object | yes | |
└stopped | string <uuid>[] | yes | The sources untracked by this call. |
└notStopped | object[] | yes | Sources that could not be untracked, with why (a trial team's own sources cannot be). |
└id | string <uuid> | yes | |
└label | string | yes | |
└error | string | yes |
| Status | Meaning | Example error |
|---|---|---|
| 400 | A body field other than agentId was sent. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 404 | Code `agent_not_found`: the team has no agent yet. Create one with PUT /api/v1/agent. | This team has no agent yet. Create one with PUT /api/v1/agent, giving at least one topic. |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 502 | The agent could not be read or saved. Sources already stopped stay stopped: GET /api/v1/agent shows the state, and stopping again is safe. |
List the Engagement Agent's leads
/api/v1/agent/leadsAgent Leads: the people the agent's sources captured, one row per person. Agent Leads is RANKED, one row per person: the job title must match (it gates); everything else lowers the ICP % (title 40, country 30, industry 15, company size 15; a part enrichment could not fill earns half; parts left empty are not counted), from 40 to 100. Each person's signal is extra_strong (engaged twice or more), strong (a comment on a post about the topics, a comment on a lead-magnet post, or a 100% ICP match on a post about the topics), medium (any other engagement) or weak (only an engagement with a hiring post or personal news). Ranked by ICP %, then signal, then newest: exactly the dashboard's Agent Leads table, built by the same code. `checked` is everyone the sources captured, which is what the team paid for; `total` is how many show. `firstRun.done` is false while the first run is still going. `filters` narrows the list as the table's Filters button does. Leads of a source the agent later stopped are kept and listed. Reads the newest 5,000 captured leads.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
agentId | string <uuid> | no | Which agent: a team may hold several, one per website (GET /api/v1/agent lists them in `agents`). Omit for the team's first agent. |
filters | string | no | The Filters rows as a JSON array of AgentFilterRow, e.g. [{"column":"icp","operator":"at_least","value":"70"}]. |
limit | integer | no | Page size, 1-200. |
offset | integer | no | How many matching leads to skip. |
Responses
200 — A page of matching leads.
| Field | Type | Required | Description |
|---|---|---|---|
leads | object[] | yes | This page of Agent Leads, ranked. |
└id | string | yes | The person's newest lead row: the id POST /api/v1/agent/push takes, and the row a push sends. |
└name | string | yes | |
└linkedinUrl | string | null | yes | |
└avatarUrl | string | null | no | |
└jobTitle | string | null | yes | |
└company | string | null | yes | |
└companyIndustry | string | null | no | |
└companyEmployeeCount | integer | null | no | |
└companyStaffRange | string | null | no | |
└companyDomain | string | null | no | |
└companyDescription | string | null | no | |
└companyLocation | string | null | no | |
└companyWebsite | string | null | no | |
└companyLinkedinUrl | string | null | no | |
└country | string | null | yes | |
└icp | integer | null | yes | How well they fit the profile. |
└icpReasons | string[] | yes | Each part, e.g. "Title: Founder", "Industry unknown (half)". |
└icpParts | object[] | no | Each part of the profile checked, its outcome and its points: the data behind icpReasons. |
└part | "title" | "country" | "industry" | "size" | yes | |
└outcome | "match" | "unknown" | "miss" | yes | unknown earns half the weight. |
└value | string | null | yes | What the lead has, or null when enrichment found nothing. |
└matched | string | null | yes | The profile entry it matched. |
└earned | number | yes | |
└weight | integer | yes | title 40, country 30, industry 15, company size 15. |
└signal | "extra_strong" | "strong" | "medium" | "weak" | yes | |
└signalReasons | string[] | yes | |
└engagementCount | integer | yes | |
└engagements | object[] | yes | Every engagement the agent saw from them, strongest first. engagementType, source, post and topic above describe the first. |
└engagementType | string | yes | Like, another reaction, Comment or Reply. |
└commentText | string | null | yes | |
└sourceId | string <uuid> | yes | |
└sourceLabel | string | yes | |
└sourceKind | "keyword" | "profile" | "company" | "post" | yes | |
└postUrl | string | null | yes | |
└topic | string | null | yes | Which of the agent's topics the post is about. |
└posterTopic | string | null | yes | For a watched person, the topic their own posts are about. |
└detectedAt | string <date-time> | yes | |
└signalPoints | integer | yes | |
└signalReasons | string[] | yes | Why this engagement scored what it did, e.g. "Commented", "Post about your topics", "Hiring post (weak)". |
└engagementType | string | yes | The strongest engagement: Like, Comment, Reply, Author or a reaction name. |
└commentText | string | null | no | |
└source | object | yes | |
└id | string | yes | |
└label | string | yes | |
└kind | "person" | "company" | "keyword" | "other" | yes | |
└active | boolean | no | |
└postText | string | null | no | |
└postUrl | string | null | no | |
└topic | string | null | yes | The agent topic the post was about. |
└posterTopic | string | null | no | For a watched person, the topic their own posts are about. |
└detectedAt | string <date-time> | yes | Their most recent engagement. |
└enriching | boolean | no | Always false here: a person whose job title and company are not filled in yet is counted in the list's `enriching` instead. |
checked | integer | yes | Everyone the agent's sources captured. The team pays one credit per new person checked, shown or not. |
enriching | integer | yes | Of those, how many are still being enriched: they are checked once their title and company fill in. |
total | integer | yes | How many people show, across all pages. |
limit | integer | yes | |
offset | integer | yes | |
hasMore | boolean | yes | Whether a later page exists. Advance `offset` by `limit`. |
agentId | string <uuid> | yes | |
firstRun | object | yes | |
└done | boolean | yes | False while the agent's first run is going: its people not yet added, a source not yet synced once, or many people still enriching. The dashboard shows progress instead of leads until it is true; it is true a day after launch whatever. |
└peopleReady | boolean | yes | |
└sourcesDone | integer | yes | |
└sourcesTotal | integer | yes | |
└enriching | integer | yes |
| Status | Meaning | Example error |
|---|---|---|
| 400 | An unknown query parameter, a limit/offset out of range, an agentId that is not a UUID, or `filters` that is not a JSON array of rows, names an unknown column, or has an operator or value its column does not take. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 404 | Code `agent_not_found`: the team has no agent yet. Create one with PUT /api/v1/agent. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 502 | The agent could not be read or saved; nothing was changed. Retry shortly. |
Send Agent Leads to the agent's webhook
/api/v1/agent/pushThe dashboard's "Push historic leads": without `leadIds`, every Agent Lead that passes the webhook's own filters; with them, those leads. Each is marked for delivery and sent as one signed POST, retried like the team's other webhooks, from the agent's sources that carry its URL. A lead already sent or on its way is not sent again. New leads go by themselves while the webhook's autoSend is on. Charges no credits.
Authenticated with the X-API-Key header.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
agentId | string <uuid> | no | Which agent: a team may hold several, one per website (GET /api/v1/agent lists them in `agents`). Omit for the team's first agent. |
leadIds | string <uuid>[] | no | Lead ids from GET /api/v1/agent/leads. Omit to send every Agent Lead that passes the webhook's filters. |
Responses
200 — Queued.
| Field | Type | Required | Description |
|---|---|---|---|
agentId | string <uuid> | yes | |
queued | integer | yes | Leads marked for delivery now. A lead already delivered or on its way is never sent again; one whose delivery failed is tried again. |
considered | integer | yes | The Agent Leads this call looked at: those passing the webhook's filters, or the `leadIds` that are Agent Leads (an id that is not one is skipped). |
| Status | Meaning | Example error |
|---|---|---|
| 400 | Code `no_webhook`: the agent has no webhook. Also a malformed body: an agentId that is not a UUID, leadIds that is not an array of up to 5,000 lead ids, or an unknown field (`unknown_field`). | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 404 | Code `agent_not_found`. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 502 | The agent could not be read, or the leads could not be queued. Nothing already queued is undone; pushing again never sends a lead twice. |
Integrations
Read the HeyReach connection
/api/v1/integrations/heyreachWhether the team has connected HeyReach, when, whether HeyReach has since rejected the key (`keyRejected`: auto-push waits until the key is reconnected on the dashboard's Integrations page), and every auto-push rule with its state. Connecting a key is dashboard-only, like API keys: the API never takes, returns or logs one. Charges nothing.
Authenticated with the X-API-Key header.
Responses
200 — The connection and its auto-push rules.
| Field | Type | Required | Description |
|---|---|---|---|
connected | boolean | yes | |
connectedAt | string | null <date-time> | yes | |
keyRejected | boolean | yes | HeyReach rejected the stored key; auto-push waits until it is reconnected on the dashboard. |
autoPush | object[] | yes |
| Status | Meaning | Example error |
|---|---|---|
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
List HeyReach campaigns that can take leads
/api/v1/integrations/heyreach/campaignsThe team's HeyReach campaigns that leads can join, read live from HeyReach with the team's stored key: in progress, paused, draft, scheduled or starting (finished, canceled and failed ones are left out). `canTakeLeads` is false for a campaign with no LinkedIn sender yet. Charges nothing.
Authenticated with the X-API-Key header.
Responses
200 — The campaigns.
| Field | Type | Required | Description |
|---|---|---|---|
campaigns | object[] | yes | |
└id | string | yes | The campaign id (digits). |
└name | string | yes | |
└status | "IN_PROGRESS" | "PAUSED" | "DRAFT" | "SCHEDULED" | "STARTING" | yes | |
└accountIds | integer[] | yes | The campaign's LinkedIn sender accounts. |
└canTakeLeads | boolean | yes | False when the campaign has no LinkedIn sender yet. |
| Status | Meaning | Example error |
|---|---|---|
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 409 | Code `not_connected` (connect HeyReach on the dashboard first) or `invalid_key` (HeyReach rejected the stored key). | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 502 | HeyReach did not answer, or refused the request. |
Send leads to a HeyReach campaign
/api/v1/integrations/heyreach/sendAdd leads to a HeyReach campaign, where its LinkedIn outreach starts. Send `leadIds` (from GET /api/v1/leads or /api/v1/agent/leads; with `agentId` each Agent Lead also carries its ICP % and signal) or `people` (influencers found by Discover, which have no lead row: their profile URL, name, title, company and country are sent as given), at most 1,000 a call. Only ENRICHED leads are sent; a lead with no LinkedIn profile URL is skipped (`skippedNoLinkedin`); a person is never sent twice to the same campaign (`alreadyInCampaign`); a paused campaign is never resumed, and a finished one is never restarted. HeyReach receives name, job title, company, country, work email (when found), the LinkedIn profile URL and the headline (as `summary`), plus custom fields `icp_percent`, `signal`, `engaged_post_url`, `comment_text` and `source_name` where known. Charges no credits.
Authenticated with the X-API-Key header.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
campaignId | string | yes | |
leadIds | string <uuid>[] | no | Leads to send. Only enriched leads are sent. |
agentId | string <uuid> | no | With leadIds from Agent Leads: adds ICP % and signal as custom fields. |
people | object[] | no | Influencers found by Discover, instead of leadIds. |
└profileUrl | string | yes | A LinkedIn profile URL (linkedin.com/in/…). |
└name | string | no | |
└jobTitle | string | no | |
└company | string | no | |
└country | string | no | |
└email | string | no |
Responses
200 — What was sent.
| Field | Type | Required | Description |
|---|---|---|---|
sent | integer | yes | People handed to HeyReach. |
added | integer | yes | |
updated | integer | yes | Already in the campaign from outside Cornersight; HeyReach updated them. |
failed | integer | yes | HeyReach could not add them. |
skippedNoLinkedin | integer | yes | No LinkedIn profile URL: never sent. |
alreadyInCampaign | integer | yes | Already sent to this campaign by Cornersight (or queued): not sent again. |
notFound | integer | yes | Lead ids that are not enriched leads on this team. |
recorded | boolean | yes | False only on a server without the delivery record yet: the send went through unrecorded. |
campaign | object | yes | |
└id | string | yes | |
└name | string | yes |
| Status | Meaning | Example error |
|---|---|---|
| 400 | A malformed body, or code `campaign_closed` / `no_senders`: the campaign cannot take leads now. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 404 | None of the leads is an enriched lead on this team, or code `agent_not_found`. | |
| 409 | Code `not_connected` or `invalid_key`. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 502 | HeyReach stopped part-way: `partial` says what was already added. |
Read a source's HeyReach auto-push, by source id
/api/v1/sources/{id}/heyreachThe HeyReach auto-push of any tracked source (person, company page, tracked post or keyword search): the campaign its new leads go to, whether new leads go automatically, its filter rows and whether the worker has paused it. `heyreach` is null when the source has none. An agent's auto-push is `heyreach` on GET /api/v1/agent. Charges nothing.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | string <uuid> | yes | The SOURCE id, as GET /api/v1/sources returns it in each source's `id`: a person, a company page, a tracked post or a keyword search. |
Responses
200 — The source and its auto-push.
| Field | Type | Required | Description |
|---|---|---|---|
sourceId | string <uuid> | yes | |
type | "person" | "company" | "post" | "keyword" | yes | |
username | string | yes | The source's stored identifier: a handle, a post URN or a search's terms. |
heyreach | object | yes | |
historic | object | no | With pushHistoric: how many past leads matched, and how many were queued (the rest were already in the campaign). |
└matched | integer | yes | |
└queued | integer | yes |
| Status | Meaning | Example error |
|---|---|---|
| 400 | Not a source id. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 404 | No such source on this team. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
Set a source's HeyReach auto-push, by source id
/api/v1/sources/{id}/heyreachSend a source's new leads to a HeyReach campaign automatically. `autoSend` (default true) sends each new lead that passes `filters` once it is enriched, from the moment it is switched on; `pushHistoric: true` also sends the leads already found that pass the filters, once. The filters are the leads tables' Filters rows; a source's leads have no ICP %, signal or engagement count, so those columns are refused (they are the Engagement Agent's ranking). The campaign must exist and be able to take leads. A body of `null`, or `{ "campaignId": null }`, removes the auto-push and drops leads still queued for it. The worker pauses an auto-push when HeyReach rejects the key (`invalid_key`) or the campaign can no longer take leads (`campaign_closed`); saving a campaign starts it again. Only ENRICHED leads are sent; a lead with no LinkedIn profile URL is skipped (`skippedNoLinkedin`); a person is never sent twice to the same campaign (`alreadyInCampaign`); a paused campaign is never resumed, and a finished one is never restarted.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | string <uuid> | yes | The SOURCE id, as GET /api/v1/sources returns it in each source's `id`: a person, a company page, a tracked post or a keyword search. |
Responses
200 — The source and its saved auto-push; `historic` when pushHistoric was sent.
| Field | Type | Required | Description |
|---|---|---|---|
sourceId | string <uuid> | yes | |
type | "person" | "company" | "post" | "keyword" | yes | |
username | string | yes | The source's stored identifier: a handle, a post URN or a search's terms. |
heyreach | object | yes | |
historic | object | no | With pushHistoric: how many past leads matched, and how many were queued (the rest were already in the campaign). |
└matched | integer | yes | |
└queued | integer | yes |
| Status | Meaning | Example error |
|---|---|---|
| 400 | A malformed body, a refused filter column, or code `campaign_closed`. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 404 | No such source, or code `campaign_not_found`. | |
| 409 | Code `not_connected` or `invalid_key`. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 503 | Code `auto_push_unavailable`: this server does not have auto-push yet. |
Enrichment
Create a profile enrichment job
/api/v1/enrich/profileCreates an asynchronous job to enrich a personal LinkedIn profile. Each matching lead that is successfully enriched consumes one enrichment credit. Leads already enriched for the tracked profile and failed or empty enrichments are not charged. On completion, the job result includes creditsCharged — the number of enriching credits this call actually consumed (0 when every matching lead was already enriched). When saveTrackedProfile=true, tracking is automatic end to end: Cornersight queues a background sync that fetches the profile's recent posts and captures ALL their engagement (reactions and comments) as leads — no separate posts/reactions/comments calls are needed to collect engagement. The sync is QUEUED, not instant: duration depends on current API load and how many posts and engagements the source has, and leads appear progressively, so an empty lead list shortly after tracking does not mean it failed. The response returns a `syncId` when a sync was queued; poll GET /api/v1/{profile|company}/{username}/sync for its stage and progress. RE-TRACKING A SOURCE THAT HAS ALREADY SYNCED DOES NOT RE-SYNC IT: no sync is queued, the response says so with `syncId: null` and `syncNotQueuedReason`, the settings you sent apply from the next scheduled run (POST /api/v1/sources/{id}/sync syncs it now, charged like any sync), and the job enriches only the profile itself. The same full capture then repeats on the daily sync. The reactions and comments endpoints remain available for immediate, targeted single-page pulls. THE `entityUrn` IN THE RESULT IS A LINKEDIN MEMBER ID (`ACoAA…`), and it is exactly the value `fromPerson` and `mentionsPerson` take on POST /api/v1/keyword/track — pass it bare or wrapped as `urn:li:person:<id>`; both are accepted. ⚠️ DO NOT USE THIS ENDPOINT MERELY TO RESOLVE AN ID: without `saveTrackedProfile` it answers 404 for anyone who is not already one of your leads, and WITH it, it tracks them — a full sync, queued and charged. `GET /api/v1/profile/{username}/urn` resolves any public handle for free instead.
Authenticated with the X-API-Key header.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
creditCapPerSync | integer <int32> | no | The most credits ONE SYNC of this tracked source may spend — CAPTURE CAP COUNTS LEAD ROWS; BILLING CHARGES ONCE PER NEW PERSON PER SOURCE (tracked_profiles.credit_cap, migration 147). PER SYNC AND NOT A LIFETIME TOTAL: the source re-syncs on its own about every 24 hours and this bounds EACH of those runs. PER SYNC, EVERY SYNC: it bounds EACH sync of that one person, company page or post — the capture cap counts lead rows, while billing charges once per new person per source — not the first pull only and not the life of the source, and there is NO ESTIMATE, NO CONFIRMATION GATE AND NO TEAM CEILING behind it: nothing prices a sync before you set the cap, raising or lowering it never needs `confirmSpend`, and the team's `dailyCeiling` does not count a credit of it. ⚠️ A KEYWORD SEARCH'S `creditCap` IS THE OTHER FIELD AND HAS ALL THREE: it is the daily bound on a RECURRING SWEEP, POST /api/v1/keyword/estimate prices it as `estimatedDailyMax`, `confirmSpend` gates a create or a raise with a `409 spend_confirmation_required`, and the team's `dailyCeiling` stops it — and `dailyCeiling` COUNTS KEYWORD SPEND ONLY, so no number of profile syncs can ever reach it. ⚠ RENAMED from `creditCap` in 4.0.0 and NOT aliased: `creditCap` now names ONLY a keyword search's per-RUN cap, a different number on a different table, and sending it here is a 400 carrying `code: "renamed_field"`. A whole number from 1 to 2147483647, or `null` for NO LIMIT, which is what every source without one is. OMITTED LEAVES THE STORED VALUE ALONE; `null` CLEARS IT. It binds EVERY sync, not the first pull. AND IT IS HOW THE LIMIT IS CHANGED: calling this endpoint again for a source the team already tracks applies the value it carries, which is the only way to edit the setting over this API — the same "a second create resumes the source and applies the settings it carries" rule POST /api/v1/keyword/track follows. 0 is REFUSED rather than stored (a source that syncs and writes nothing is a PAUSED source, and `status` already says that), as are fractions and values past int4 — the worker resolves those to "no cap", so storing one would show a limit that binds nothing. ⚠️ REQUIRES `saveTrackedProfile: true`: without it this call creates no tracked source for the limit to apply to, so sending it is a 400 rather than a 200 that dropped it. A CAP NEEDS A TRACKED SOURCE TO SIT ON — it is stored on the source row, so an enrichment that saves no source has nothing to carry it. |
username | string | yes | LinkedIn personal profile or company `username`, not a full URL. |
saveTrackedProfile | boolean | no | Effective default is `false`. With `false`, Cornersight updates existing leads and the job fails if no compatible lead exists. With `true`, Cornersight creates or updates the tracked personal profile first. On a source the team already tracks, `true` applies the capture settings sent with it and queues a sync only if the source has NEVER completed one (or is being brought back from untracked); a source that has synced before is not re-synced, and the response says so with `syncId: null` and `syncNotQueuedReason`.default: false |
captureReplies | boolean | no | Whether the tracked source captures reply authors as leads. False skips replies before lead writes and credits. Requires saveTrackedProfile: true.default: true |
enrichLeads | boolean | no | Whether this source's leads are enriched. Default true, what every source has always done. RAW MODE when false: this source's engagers are still captured and charged exactly as before — one credit per NEW person per source, repeats free, the same ledger and caps — but NEVER enriched: no job title, company or country, and no enrichment provider call. Those leads end enrichment status `raw` and are read with GET /api/v1/leads/raw; they never appear in GET /api/v1/leads, /engagers, exports, webhooks or integrations. A plain setting, not a spend change: the price is the same either way, so it never needs `confirmSpend`. It applies to leads captured or processed AFTER the change — leads already enriched stay enriched and raw leads stay raw. Requires saveTrackedProfile: true — without it this call creates no source for the setting to sit on and is refused with a 400 rather than the field dropped. Re-tracking an existing source with it CHANGES the setting; omitting it leaves the stored value alone.default: true |
mode | "engagers" | "posts_only" | no | WHAT ONE SYNC OF THIS TRACKED SOURCE DOES (tracked_profiles.sync_mode, migration 158). `engagers` is the default and what every tracked source has always done: sweep the posts and fan out across the reactions and comments on each one, writing a lead per engager and charging ONE CREDIT PER NEW PERSON FOR THIS SOURCE; repeat engagements are free — a single post with 300 distinct new people can cost up to 300 credits. `posts_only` fetches this source's NEW POSTS newest first and stops — no engagers, no leads, no enrichment — charging ONE CREDIT PER NEW POST, up to `postsPerSync`, and never twice for the same post. ⚠ ASK WHICH IS WANTED BEFORE CREATING ANYTHING: "watch what this person posts" and "find me the people who engage with them" are different products at very different prices. ⚠ `posts_only` REQUIRES `postsPerSync` AND `confirmSpend`. ⚠ REQUIRES `saveTrackedProfile: true` — without it this call creates no source for a mode to apply to, and sending it is a 400 rather than a 200 that dropped it. Sending it for a source the team ALREADY tracks CHANGES its mode, which is how it is edited: switching an existing posts-only source TO `engagers` is a SPEND INCREASE and needs `confirmSpend`, while switching the other way never does. Read it back as `mode` on GET /api/v1/sources, where a posts-only source also reports `postsPerSync` and a `lastRun` carrying `postsFetched` and `creditsSpent`.default: "engagers" |
postsPerSync | integer | no | The most posts ONE SYNC of a posts-only source may fetch, 1-60 — and, because one post is one credit, the most it can cost in a day: the N in "up to N credits a day", which is the figure the customer confirms. REQUIRED with `mode: "posts_only"` and refused without it, because that sentence is what is being agreed to and there is no N to put in it otherwise. It NEVER BACKFILLS: the first sync buys up to N of the newest posts, and each later sync only posts newer than the newest one it already holds (never older ones it has not seen), so a day with no new post costs nothing. `creditCapPerSync` applies as well and the TIGHTER of the two binds a run, because for this mode one post IS one credit. |
confirmSpend | boolean | no | Authorises the RECURRING charge a posts-only source creates. Without it the call is refused 409 `spend_confirmation_required` and NOTHING is created; that refusal carries `estimatedDailyMax` (equal to `postsPerSync`), `daysToExhaustAtCap` — both from the same shared estimate function every other spend surface quotes — and `remainingBalance`. SAY THE DAILY FIGURE AND WAIT FOR AN ANSWER, then re-send the identical request with `confirmSpend: true`. It is also what authorises switching an existing posts-only source back to the engagers sweep. This is a STANDING charge until the source is untracked, not a one-off. |
firstSyncPosts | integer | no | How many of the LATEST posts this source's FIRST sync collects: a whole number from 1 to 50, or `null` for the default (the latest 15 posts). With `firstSyncDays` as well, it caps how many of that window's posts are collected (tracked_profiles.first_sync_posts, migration 188). FIRST SYNC ONLY: every later sync checks the 4 newest posts, and a source that has already synced ignores it (re-adding an untracked source stores the new value, which takes effect only if that source has never synced). Every collected post's engagers are captured and charged as usual, and `creditCapPerSync` still bounds the first sync. Omitted leaves the stored value alone. Requires `saveTrackedProfile: true` (a 400 without it) and an ENGAGERS source: sent with `mode: "posts_only"` it is a 400, because a posts-only watch's first run takes its `postsPerSync` newest posts. A tracked post and a keyword search have no first-sync window: POST /api/v1/post/track and POST /api/v1/keyword/track do not take this field. |
firstSyncDays | integer | no | Collect only the posts PUBLISHED IN THE LAST N DAYS on this source's FIRST sync: a whole number from 1 to 90, or `null` for no time limit. At most 50 posts, or at most `firstSyncPosts` when both are sent; a post whose publish time is unknown is left out (tracked_profiles.first_sync_days, migration 188). FIRST SYNC ONLY: every later sync checks the 4 newest posts, and a source that has already synced ignores it (re-adding an untracked source stores the new value, which takes effect only if that source has never synced). Every collected post's engagers are captured and charged as usual, and `creditCapPerSync` still bounds the first sync. Omitted leaves the stored value alone. Requires `saveTrackedProfile: true` (a 400 without it) and an ENGAGERS source: sent with `mode: "posts_only"` it is a 400, because a posts-only watch's first run takes its `postsPerSync` newest posts. A tracked post and a keyword search have no first-sync window: POST /api/v1/post/track and POST /api/v1/keyword/track do not take this field. |
Request examples
{
"username": "demo-profile",
"saveTrackedProfile": false
}{
"username": "demo-profile",
"saveTrackedProfile": true
}{
"username": "demo-profile",
"saveTrackedProfile": true,
"mode": "posts_only",
"postsPerSync": 5,
"confirmSpend": true
}{
"username": "demo-profile",
"saveTrackedProfile": true,
"firstSyncDays": 30,
"firstSyncPosts": 20
}Responses
200 — Job accepted. Poll the status endpoint while the job is `pending` or `running`, until it reaches `completed` or `failed`.
{
"jobId": "00000000-0000-4000-8000-000000000001"
}| Status | Meaning | Example error |
|---|---|---|
| 400 | The `username` field is required. | username is required |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The authenticated team does not have an active subscription or an unexpired trial. | Team subscription not active |
| 404 | No compatible lead exists for the requested enrichment. | Lead not found for demo-profile. Set saveTrackedProfile=true to create or sync a tracked profile instead. |
| 409 | TWO REFUSALS SHARE THIS STATUS AND `code` TELLS THEM APART. `spend_confirmation_required` — a posts-only source was asked for without `confirmSpend`. Nothing was created and nothing was charged. The body carries `mode`, `postsPerSync`, `estimatedDailyMax` (equal to `postsPerSync`), `daysToExhaustAtCap` and `remainingBalance`; say the daily figure to whoever is paying, wait for an answer, then re-send the identical request with `confirmSpend: true`. The same code POST /api/v1/keyword/track answers with, deliberately. Code `identifier_in_use`. Only with `saveTrackedProfile: true`. A source identifier is unique per team across all four source kinds, and this handle is already held by a source of a DIFFERENT kind — a keyword search whose joined terms are that handle, or a tracked post — so the tracked profile cannot be created. The message names the source holding it. Nothing was created, no credit was charged, and the enrichment did not run. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 500 | Unexpected server error — the request failed on our side (e.g. a database error), not because of the request itself. On a WRITE endpoint some work may already have been applied before the failure, so prefer re-reading state over blindly retrying, or you can end up with duplicates. | Failed to list leads |
Create a company enrichment job
/api/v1/enrich/companyCreates an asynchronous job to enrich a LinkedIn company page. Company enrichment never consumes enriching credits; the completed job result includes creditsCharged: 0. When saveTrackedProfile=true, tracking is automatic end to end: Cornersight queues a background sync that fetches the profile's recent posts and captures ALL their engagement (reactions and comments) as leads — no separate posts/reactions/comments calls are needed to collect engagement. The sync is QUEUED, not instant: duration depends on current API load and how many posts and engagements the source has, and leads appear progressively, so an empty lead list shortly after tracking does not mean it failed. The response returns a `syncId` when a sync was queued; poll GET /api/v1/{profile|company}/{username}/sync for its stage and progress. RE-TRACKING A SOURCE THAT HAS ALREADY SYNCED DOES NOT RE-SYNC IT: no sync is queued, the response says so with `syncId: null` and `syncNotQueuedReason`, the settings you sent apply from the next scheduled run (POST /api/v1/sources/{id}/sync syncs it now, charged like any sync), and the job enriches only the profile itself. The same full capture then repeats on the daily sync. The reactions and comments endpoints remain available for immediate, targeted single-page pulls. On completion the job `result` carries the full firmographic set (industry, employee count and size range, headquarters, description, founded year, follower count, specialties, and a stable company URN) — see the CompanyEnrichmentResult schema. BREAKING (2026-07-18): these fields are now FLAT on `result`, matching profile enrichment; the previous `result.data` envelope and the capital-I `Images` key are gone (`images.logo` now).
Authenticated with the X-API-Key header.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
creditCapPerSync | integer <int32> | no | The most credits ONE SYNC of this tracked source may spend — CAPTURE CAP COUNTS LEAD ROWS; BILLING CHARGES ONCE PER NEW PERSON PER SOURCE (tracked_profiles.credit_cap, migration 147). PER SYNC AND NOT A LIFETIME TOTAL: the source re-syncs on its own about every 24 hours and this bounds EACH of those runs. PER SYNC, EVERY SYNC: it bounds EACH sync of that one person, company page or post — the capture cap counts lead rows, while billing charges once per new person per source — not the first pull only and not the life of the source, and there is NO ESTIMATE, NO CONFIRMATION GATE AND NO TEAM CEILING behind it: nothing prices a sync before you set the cap, raising or lowering it never needs `confirmSpend`, and the team's `dailyCeiling` does not count a credit of it. ⚠️ A KEYWORD SEARCH'S `creditCap` IS THE OTHER FIELD AND HAS ALL THREE: it is the daily bound on a RECURRING SWEEP, POST /api/v1/keyword/estimate prices it as `estimatedDailyMax`, `confirmSpend` gates a create or a raise with a `409 spend_confirmation_required`, and the team's `dailyCeiling` stops it — and `dailyCeiling` COUNTS KEYWORD SPEND ONLY, so no number of profile syncs can ever reach it. ⚠ RENAMED from `creditCap` in 4.0.0 and NOT aliased: `creditCap` now names ONLY a keyword search's per-RUN cap, a different number on a different table, and sending it here is a 400 carrying `code: "renamed_field"`. A whole number from 1 to 2147483647, or `null` for NO LIMIT, which is what every source without one is. OMITTED LEAVES THE STORED VALUE ALONE; `null` CLEARS IT. It binds EVERY sync, not the first pull. AND IT IS HOW THE LIMIT IS CHANGED: calling this endpoint again for a source the team already tracks applies the value it carries, which is the only way to edit the setting over this API — the same "a second create resumes the source and applies the settings it carries" rule POST /api/v1/keyword/track follows. 0 is REFUSED rather than stored (a source that syncs and writes nothing is a PAUSED source, and `status` already says that), as are fractions and values past int4 — the worker resolves those to "no cap", so storing one would show a limit that binds nothing. ⚠️ REQUIRES `saveTrackedProfile: true`: without it this call creates no tracked source for the limit to apply to, so sending it is a 400 rather than a 200 that dropped it. A CAP NEEDS A TRACKED SOURCE TO SIT ON — it is stored on the source row, so an enrichment that saves no source has nothing to carry it. |
username | string | yes | LinkedIn personal profile or company `username`, not a full URL. |
saveTrackedProfile | boolean | no | Effective default is `false`. With `false`, Cornersight updates existing compatible company leads and the job fails if no compatible lead exists. With `true`, Cornersight creates or updates the tracked company profile first. On a source the team already tracks, `true` applies the capture settings sent with it and queues a sync only if the source has NEVER completed one (or is being brought back from untracked); a source that has synced before is not re-synced, and the response says so with `syncId: null` and `syncNotQueuedReason`.default: false |
captureReplies | boolean | no | Whether the tracked source captures reply authors as leads. False skips replies before lead writes and credits. Requires saveTrackedProfile: true.default: true |
enrichLeads | boolean | no | Whether this source's leads are enriched. Default true, what every source has always done. RAW MODE when false: this source's engagers are still captured and charged exactly as before — one credit per NEW person per source, repeats free, the same ledger and caps — but NEVER enriched: no job title, company or country, and no enrichment provider call. Those leads end enrichment status `raw` and are read with GET /api/v1/leads/raw; they never appear in GET /api/v1/leads, /engagers, exports, webhooks or integrations. A plain setting, not a spend change: the price is the same either way, so it never needs `confirmSpend`. It applies to leads captured or processed AFTER the change — leads already enriched stay enriched and raw leads stay raw. Requires saveTrackedProfile: true — without it this call creates no source for the setting to sit on and is refused with a 400 rather than the field dropped. Re-tracking an existing source with it CHANGES the setting; omitting it leaves the stored value alone.default: true |
mode | "engagers" | "posts_only" | no | WHAT ONE SYNC OF THIS TRACKED SOURCE DOES (tracked_profiles.sync_mode, migration 158). `engagers` is the default and what every tracked source has always done: sweep the posts and fan out across the reactions and comments on each one, writing a lead per engager and charging ONE CREDIT PER NEW PERSON FOR THIS SOURCE; repeat engagements are free — a single post with 300 distinct new people can cost up to 300 credits. `posts_only` fetches this source's NEW POSTS newest first and stops — no engagers, no leads, no enrichment — charging ONE CREDIT PER NEW POST, up to `postsPerSync`, and never twice for the same post. ⚠ ASK WHICH IS WANTED BEFORE CREATING ANYTHING: "watch what this person posts" and "find me the people who engage with them" are different products at very different prices. ⚠ `posts_only` REQUIRES `postsPerSync` AND `confirmSpend`. ⚠ REQUIRES `saveTrackedProfile: true` — without it this call creates no source for a mode to apply to, and sending it is a 400 rather than a 200 that dropped it. Sending it for a source the team ALREADY tracks CHANGES its mode, which is how it is edited: switching an existing posts-only source TO `engagers` is a SPEND INCREASE and needs `confirmSpend`, while switching the other way never does. Read it back as `mode` on GET /api/v1/sources, where a posts-only source also reports `postsPerSync` and a `lastRun` carrying `postsFetched` and `creditsSpent`.default: "engagers" |
postsPerSync | integer | no | The most posts ONE SYNC of a posts-only source may fetch, 1-60 — and, because one post is one credit, the most it can cost in a day: the N in "up to N credits a day", which is the figure the customer confirms. REQUIRED with `mode: "posts_only"` and refused without it, because that sentence is what is being agreed to and there is no N to put in it otherwise. It NEVER BACKFILLS: the first sync buys up to N of the newest posts, and each later sync only posts newer than the newest one it already holds (never older ones it has not seen), so a day with no new post costs nothing. `creditCapPerSync` applies as well and the TIGHTER of the two binds a run, because for this mode one post IS one credit. |
confirmSpend | boolean | no | Authorises the RECURRING charge a posts-only source creates. Without it the call is refused 409 `spend_confirmation_required` and NOTHING is created; that refusal carries `estimatedDailyMax` (equal to `postsPerSync`), `daysToExhaustAtCap` — both from the same shared estimate function every other spend surface quotes — and `remainingBalance`. SAY THE DAILY FIGURE AND WAIT FOR AN ANSWER, then re-send the identical request with `confirmSpend: true`. It is also what authorises switching an existing posts-only source back to the engagers sweep. This is a STANDING charge until the source is untracked, not a one-off. |
firstSyncPosts | integer | no | How many of the LATEST posts this source's FIRST sync collects: a whole number from 1 to 50, or `null` for the default (the latest 15 posts). With `firstSyncDays` as well, it caps how many of that window's posts are collected (tracked_profiles.first_sync_posts, migration 188). FIRST SYNC ONLY: every later sync checks the 4 newest posts, and a source that has already synced ignores it (re-adding an untracked source stores the new value, which takes effect only if that source has never synced). Every collected post's engagers are captured and charged as usual, and `creditCapPerSync` still bounds the first sync. Omitted leaves the stored value alone. Requires `saveTrackedProfile: true` (a 400 without it) and an ENGAGERS source: sent with `mode: "posts_only"` it is a 400, because a posts-only watch's first run takes its `postsPerSync` newest posts. A tracked post and a keyword search have no first-sync window: POST /api/v1/post/track and POST /api/v1/keyword/track do not take this field. |
firstSyncDays | integer | no | Collect only the posts PUBLISHED IN THE LAST N DAYS on this source's FIRST sync: a whole number from 1 to 90, or `null` for no time limit. At most 50 posts, or at most `firstSyncPosts` when both are sent; a post whose publish time is unknown is left out (tracked_profiles.first_sync_days, migration 188). FIRST SYNC ONLY: every later sync checks the 4 newest posts, and a source that has already synced ignores it (re-adding an untracked source stores the new value, which takes effect only if that source has never synced). Every collected post's engagers are captured and charged as usual, and `creditCapPerSync` still bounds the first sync. Omitted leaves the stored value alone. Requires `saveTrackedProfile: true` (a 400 without it) and an ENGAGERS source: sent with `mode: "posts_only"` it is a 400, because a posts-only watch's first run takes its `postsPerSync` newest posts. A tracked post and a keyword search have no first-sync window: POST /api/v1/post/track and POST /api/v1/keyword/track do not take this field. |
Request examples
{
"username": "demo-company",
"saveTrackedProfile": false
}{
"username": "demo-company",
"saveTrackedProfile": true
}{
"username": "instantlyapp",
"saveTrackedProfile": true,
"mode": "posts_only",
"postsPerSync": 5,
"confirmSpend": true
}{
"username": "demo-company",
"saveTrackedProfile": true,
"firstSyncPosts": 5
}Responses
200 — Job accepted. Poll the status endpoint while the job is `pending` or `running`, until it reaches `completed` or `failed`.
{
"jobId": "00000000-0000-4000-8000-000000000002"
}| Status | Meaning | Example error |
|---|---|---|
| 400 | The `username` field is required. | username is required |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The authenticated team does not have an active subscription or an unexpired trial. | Team subscription not active |
| 404 | No compatible company lead exists for the requested enrichment. | Lead not found for company Demo Company. Set saveTrackedProfile=true to create or sync a tracked profile instead. |
| 409 | TWO REFUSALS SHARE THIS STATUS AND `code` TELLS THEM APART. `spend_confirmation_required` — a posts-only source was asked for without `confirmSpend`. Nothing was created and nothing was charged. The body carries `mode`, `postsPerSync`, `estimatedDailyMax` (equal to `postsPerSync`), `daysToExhaustAtCap` and `remainingBalance`; say the daily figure to whoever is paying, wait for an answer, then re-send the identical request with `confirmSpend: true`. The same code POST /api/v1/keyword/track answers with, deliberately. Code `identifier_in_use`. Only with `saveTrackedProfile: true`. A source identifier is unique per team across all four source kinds, and this handle is already held by a source of a DIFFERENT kind — a keyword search whose joined terms are that handle, or a tracked post — so the tracked profile cannot be created. The message names the source holding it. Nothing was created, no credit was charged, and the enrichment did not run. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 500 | Unexpected server error — the request failed on our side (e.g. a database error), not because of the request itself. On a WRITE endpoint some work may already have been applied before the failure, so prefer re-reading state over blindly retrying, or you can end up with duplicates. | Failed to list leads |
Tracked Profiles
Change a tracked person's per-sync credit limit
/api/v1/profile/{username}Change a tracked personal profile's PER-SYNC CREDIT LIMIT without re-tracking it. THE EDIT THAT COSTS NOTHING: `creditCapPerSync` otherwise reaches a personal profile only through POST /api/v1/enrich/profile, which also runs an enrichment job — and which, on a source that has already synced, queues NO sync: the setting simply applies from the next scheduled run. This route writes the column and nothing else: no sync is queued, no lead is enriched, nothing is charged. ADDRESSED BY THE SAME USERNAME the DELETE on this path takes, so a caller already holds the identifier. An earlier release put this edit on PATCH /api/v1/sources/{id} instead, keyed by source id; that route is REMOVED in favour of these two, which is a breaking change. AN EDIT BINDS FROM THE NEXT SYNC, NEVER THE ONE ALREADY RUNNING. A run reads the source's limit when it starts and holds it for the whole run, so a cap lowered mid-sweep does not cut that sweep short. Whether the run you are looking at actually ended at the limit is reported by GET /api/v1/profile/{username}/sync as `stoppedBy: "credit_cap"`. NOT A KEYWORD SEARCH'S CAP. `creditCap` is a keyword search's per-RUN limit, it lives on keyword_searches, PATCH /api/v1/keyword/{id} writes it and GET /api/v1/sources reports it as `config.creditCap`. The two fields shared the name `creditCap` until this release; they no longer do, and there is no alias — a body still sending `creditCap` here is a 400 carrying `code: "renamed_field"` rather than a 200 that discarded it. AT LEAST ONE SETTING IS REQUIRED: `creditCapPerSync` (which may be null), `mode`, `captureReplies` or `enrichLeads`. Absent means "leave it alone" everywhere else in this API, and a request that changes nothing cannot honestly be answered 200 — so a body naming none of them is a 400, and `null` is the value that REMOVES a limit. `enrichLeads: false` puts the source in RAW MODE (captured and charged as before, never enriched, read with GET /api/v1/leads/raw); like the cap it is an edit that queues nothing, charges nothing and needs no `confirmSpend`, and it applies to leads captured after it. THE MODE CAN BE CHANGED HERE TOO: `mode: "posts_only"` with `postsPerSync` (1-60) makes the source a posts-only watch (new posts only, one credit per new post, no leads), and `mode: "engagers"` turns it back into the engagers sweep. Switching posts-only to engagers can raise the cost, so it is refused 409 `spend_confirmation_required` until the request is re-sent with `confirmSpend: true`; switching to posts-only never needs it. A `mode` change whose current mode cannot be read is a 502 and changes nothing. UNKNOWN FIELDS ARE REFUSED: any other property is a 400 carrying `code: "unknown_field"` and naming it.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | Public identifier of the tracked personal profile (not a full URL and not a source id), e.g. demo-profile. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
captureReplies | boolean | no | Whether this tracked source captures reply authors as leads. False skips replies before lead writes and credits. |
mode | "engagers" | "posts_only" | no | Switch what each sync of this source does (tracked_profiles.sync_mode). `engagers` sweeps the source's posts and captures the people who engaged as leads, one credit per NEW person per source. `posts_only` fetches the source's new posts only, newest first, one credit per new post up to `postsPerSync`, and writes no leads. Omit it to leave the mode unchanged. Switching from `posts_only` to `engagers` is a SPEND INCREASE: it is refused 409 `spend_confirmation_required` and nothing is written until the same request is re-sent with `confirmSpend: true`. Switching to `posts_only` can only cost less and never needs it. Binds from the next sync. |
postsPerSync | integer | no | With `mode: "posts_only"` only, and REQUIRED with it: the most posts ONE SYNC of the source may fetch, 1-60, which, because one post is one credit, is also the most it can cost in a day. Refused with `mode: "engagers"` and refused without `mode` (a body naming only `postsPerSync` reads as engagers mode and is a 400). |
confirmSpend | boolean | no | Authorises switching a posts-only source to the engagers sweep, the one change on this route that can raise what the source costs. Without it that switch is refused 409 `spend_confirmation_required`, carrying `previous` (the mode and postsPerSync in force), the requested `mode` and the source's `creditCapPerSync`, and NOTHING is changed: say the cost to the person, wait for a yes, then re-send the identical request with `confirmSpend: true`. Ignored by every other change, and not a setting on its own: a body naming only `confirmSpend` is the 400 for a request that changes nothing. |
enrichLeads | boolean | no | Whether this source's leads are enriched. Default true, what every source has always done. RAW MODE when false: this source's engagers are still captured and charged exactly as before — one credit per NEW person per source, repeats free, the same ledger and caps — but NEVER enriched: no job title, company or country, and no enrichment provider call. Those leads end enrichment status `raw` and are read with GET /api/v1/leads/raw; they never appear in GET /api/v1/leads, /engagers, exports, webhooks or integrations. A plain setting, not a spend change: the price is the same either way, so it never needs `confirmSpend`. It applies to leads captured or processed AFTER the change — leads already enriched stay enriched and raw leads stay raw. Omit it to leave the stored setting alone. |
creditCapPerSync | integer | null | no | The most enriching credits ONE SYNC of this source may spend — CAPTURE CAP COUNTS LEAD ROWS; BILLING CHARGES ONCE PER NEW PERSON PER SOURCE — or `null` for NO LIMIT, which is the state of every source nobody set one on. PER SYNC AND NOT A LIFETIME TOTAL: the source re-syncs about every 24 hours and this bounds each of those runs. PER SYNC, EVERY SYNC: it bounds EACH sync of that one person, company page or post — the capture cap counts lead rows, while billing charges once per new person per source — not the first pull only and not the life of the source, and there is NO ESTIMATE, NO CONFIRMATION GATE AND NO TEAM CEILING behind it: nothing prices a sync before you set the cap, raising or lowering it never needs `confirmSpend`, and the team's `dailyCeiling` does not count a credit of it. ⚠️ A KEYWORD SEARCH'S `creditCap` IS THE OTHER FIELD AND HAS ALL THREE: it is the daily bound on a RECURRING SWEEP, POST /api/v1/keyword/estimate prices it as `estimatedDailyMax`, `confirmSpend` gates a create or a raise with a `409 spend_confirmation_required`, and the team's `dailyCeiling` stops it — and `dailyCeiling` COUNTS KEYWORD SPEND ONLY, so no number of profile syncs can ever reach it. 0 is refused (migration 147's CHECK is `credit_cap > 0`; a source that syncs and writes nothing is a PAUSED source, which `status` already says), as are fractions and values past int4. |
Request examples
{
"creditCapPerSync": 250
}{
"creditCapPerSync": null
}{
"mode": "posts_only",
"postsPerSync": 5
}{
"mode": "engagers",
"confirmSpend": true
}{
"enrichLeads": false
}Responses
200 — The limit now in force, and the one it replaced.
| Field | Type | Required | Description |
|---|---|---|---|
sourceId | string <uuid> | no | |
type | "person" | no | |
username | string | no | The source's stored handle, exactly as GET /api/v1/sources reports it. |
creditCapPerSync | integer | null | no | The limit now stored. `null` means no limit. Per sync, every sync — the capture cap counts lead rows, while billing charges once per new person per source, with no estimate, no confirmation gate and no team ceiling: the team's `dailyCeiling` counts KEYWORD spend only. Not a keyword search's `creditCap`, which is the daily bound on a recurring sweep and has all three. |
mode | "engagers" | "posts_only" | no | Present only when the request named `mode`: the mode now stored. |
postsPerSync | integer | null | no | Present only when the request named `mode`: the posts-only bound now stored, `null` in engagers mode. |
captureReplies | boolean | no | Present only when the request named it: the reply-capture setting now stored. |
enrichLeads | boolean | no | Present only when the request named it: the raw-mode setting now stored (false = raw). |
previous | object | no | What it was before this call, so a caller can report the change without having read the source first. |
└creditCapPerSync | integer | null | no | The limit this call replaced — `null` when the source had none. Per sync, every sync — the capture cap counts lead rows, while billing charges once per new person per source, with no estimate, no confirmation gate and no team ceiling: the team's `dailyCeiling` counts KEYWORD spend only. Not a keyword search's `creditCap`, which is the daily bound on a recurring sweep and has all three. |
└mode | "engagers" | "posts_only" | no | The mode before this call. Present only when the request named `mode`. |
└postsPerSync | integer | null | no | The posts-only bound before this call, `null` in engagers mode. Present only when the request named `mode`. |
└captureReplies | boolean | no | The reply-capture setting before this call. Always present. |
└enrichLeads | boolean | no | The raw-mode setting before this call (true = enriching). Always present. |
{
"sourceId": "8f1c…",
"type": "person",
"username": "demo-profile",
"creditCapPerSync": 250,
"previous": {
"creditCapPerSync": 5000
}
}| Status | Meaning | Example error |
|---|---|---|
| 400 | No setting in the body (none of `creditCapPerSync`, `mode`, `captureReplies`, `enrichLeads`), an invalid `mode`, a `postsPerSync` outside 1-60 or sent without `mode: "posts_only"`, a value outside 1–2147483647, a non-boolean `captureReplies` or `enrichLeads`, an unknown field (`unknown_field`), or the renamed `creditCap` (`renamed_field`). | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The authenticated team does not have an active subscription or an unexpired trial. | Team subscription not active |
| 404 | No such tracked personal profile for this team, or it has been untracked. | |
| 409 | `spend_confirmation_required`: the body switches a posts-only source to the engagers sweep without `confirmSpend: true`. Nothing was written. The body carries `error`, `code`, `previous` ({ mode, postsPerSync }), the requested `mode` and the source's `creditCapPerSync`; say the cost to the person and re-send with `confirmSpend: true` only after a yes. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 500 | Unexpected server error — the request failed on our side (e.g. a database error), not because of the request itself. On a WRITE endpoint some work may already have been applied before the failure, so prefer re-reading state over blindly retrying, or you can end up with duplicates. | Failed to list leads |
| 502 | A `mode` change could not read the source's current mode (or the write failed), so nothing was changed. Retry. |
Untrack a profile
/api/v1/profile/{username}Stop tracking a personal LinkedIn profile. Synchronous. SOFT DELETE, the same contract as untracking a post or a keyword search: the source is DEACTIVATED (`status: "inactive"`), never removed, and nothing it captured is destroyed. It cannot be a row delete — `leads.tracked_profile_id` is `NOT NULL ... ON DELETE CASCADE`, so deleting the source would take every lead it captured with it, which is exactly the data loss this endpoint stopped causing. ACCESS IS A SEPARATE QUESTION FROM STORAGE, and this endpoint answers both differently: nothing is erased, but the leads it captured STOP BEING SERVED — GET /api/v1/leads and GET /api/v1/engagers exclude them from the all-sources view and return `404` for its `profileId`/`username`, exactly as the dashboard, its stat cards and its CSV export do, and the source stops being actionable (POST /api/v1/profile/{username}/push and its `/webhook` and `/icp` routes all `404`). `GET /api/v1/sources?includeInactive=true` enumerates what you used to track; that call lists the source and does not serve its leads. THE LEADS HAVE THEIR OWN OPT-IN: pass the same parameter to the endpoints that serve them — `GET /api/v1/leads?includeInactive=true` and `GET /api/v1/engagers?includeInactive=true` — and the kept leads are returned and this `username`/`profileId` resolves instead of 404ing. It is an API-only opt-in and the default is unchanged, so the dashboard, its stat cards and its CSV export still show nothing for this source. Reactivating does: re-tracking the same username revives the source and its leads are read again. A `404` here means out of scope for that read, never that anything was erased — erasure is a support request, not an API call. Keyword searches are the one kind whose leads stay readable after untracking; see DELETE /api/v1/keyword/{id}. The sweep stops, the source leaves GET /api/v1/sources unless you pass `?includeInactive=true`, and re-tracking the same username REVIVES that source rather than creating a second one. Credits already spent are never refunded. Scoped to the authenticated team; a profile the team does not track returns `404`. Trial profiles cannot be deleted (`403`) — subscribe to a paid plan to manage profiles. WHAT HAPPENS TO WORK THAT IS ALREADY RUNNING — the half this used to leave unsaid, and the half that costs money. A SWEEP ALREADY RUNNING IS STOPPED: the run asks whether its source is still tracked at every checkpoint it passes (before each provider call, before each post is harvested, before each chunk of lead rows) and abandons the run at the first checkpoint after the delete. THAT IS “AT THE NEXT CHECKPOINT”, NOT “INSTANTLY”: a provider call already in flight finishes first, and the check is coalesced behind a short window (one second by default), so expect the stop within moments rather than at the instant the delete returns. If the status read itself fails the run CARRIES ON to the next checkpoint — the control channel fails open on purpose, because abandoning a paying customer's sweep over one timed-out SELECT is the worse error. LEADS ALREADY WRITTEN ARE KEPT, and the run is finalised as `completed` with `stoppedBy: "untracked"` on GET /api/v1/sources/{id}/sync (and the username-keyed /sync routes). ⚠️ THAT VALUE IS NOT IN `lastRun.stoppedBy` on GET /api/v1/sources and never will be: that enum describes how a keyword SWEEP ended and is a closed set. QUEUED ENRICHMENT IS WRITTEN OFF AND NOT CHARGED: enrichment lands minutes to hours after capture, so the backlog is the part of an untracked source that would otherwise go on billing — every lead of this source still `pending` or `processing` is marked `skipped` with the reason `source_untracked` and costs no enriching credits. Re-tracking this source returns exactly those leads to `pending` at the start of its next sync, and returns no other skipped lead with it.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | Public identifier of the tracked personal profile (not a full URL), e.g. demo-profile. |
Responses
200 — Profile untracked and deleted.
| Field | Type | Required | Description |
|---|---|---|---|
ok | boolean | yes | |
username | string | yes | The (normalized) public identifier of the deleted tracked profile. |
profileType | "person" | "company" | yes | |
untracked | boolean | yes | Always `true` — the tracked profile and its cascaded records were deleted. |
{
"ok": true,
"username": "demo-profile",
"profileType": "person",
"untracked": true
}| Status | Meaning | Example error |
|---|---|---|
| 400 | The `username` field is required. | username is required |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The delete is forbidden — either the team's subscription is inactive / its trial has expired, or the profile is a trial profile (trial profiles cannot be deleted; subscribe to a paid plan to manage profiles). | Team subscription not active |
| 404 | The personal LinkedIn profile is not tracked by the authenticated team, or it has been untracked. | Profile not tracked |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 500 | Unexpected server error — the request failed on our side (e.g. a database error), not because of the request itself. On a WRITE endpoint some work may already have been applied before the failure, so prefer re-reading state over blindly retrying, or you can end up with duplicates. | Failed to list leads |
Change a tracked company page's per-sync credit limit
/api/v1/company/{username}Change a tracked company page's PER-SYNC CREDIT LIMIT without re-tracking it. THE EDIT THAT COSTS NOTHING: `creditCapPerSync` otherwise reaches a company page only through POST /api/v1/enrich/company, which also runs an enrichment job — and which, on a source that has already synced, queues NO sync: the setting simply applies from the next scheduled run. This route writes the column and nothing else: no sync is queued, no lead is enriched, nothing is charged. ADDRESSED BY THE SAME USERNAME the DELETE on this path takes, so a caller already holds the identifier. An earlier release put this edit on PATCH /api/v1/sources/{id} instead, keyed by source id; that route is REMOVED in favour of these two, which is a breaking change. AN EDIT BINDS FROM THE NEXT SYNC, NEVER THE ONE ALREADY RUNNING. A run reads the source's limit when it starts and holds it for the whole run, so a cap lowered mid-sweep does not cut that sweep short. Whether the run you are looking at actually ended at the limit is reported by GET /api/v1/company/{username}/sync as `stoppedBy: "credit_cap"`. NOT A KEYWORD SEARCH'S CAP. `creditCap` is a keyword search's per-RUN limit, it lives on keyword_searches, PATCH /api/v1/keyword/{id} writes it and GET /api/v1/sources reports it as `config.creditCap`. The two fields shared the name `creditCap` until this release; they no longer do, and there is no alias — a body still sending `creditCap` here is a 400 carrying `code: "renamed_field"` rather than a 200 that discarded it. AT LEAST ONE SETTING IS REQUIRED: `creditCapPerSync` (which may be null), `mode`, `captureReplies` or `enrichLeads`. Absent means "leave it alone" everywhere else in this API, and a request that changes nothing cannot honestly be answered 200 — so a body naming none of them is a 400, and `null` is the value that REMOVES a limit. `enrichLeads: false` puts the source in RAW MODE (captured and charged as before, never enriched, read with GET /api/v1/leads/raw); like the cap it is an edit that queues nothing, charges nothing and needs no `confirmSpend`, and it applies to leads captured after it. THE MODE CAN BE CHANGED HERE TOO: `mode: "posts_only"` with `postsPerSync` (1-60) makes the source a posts-only watch (new posts only, one credit per new post, no leads), and `mode: "engagers"` turns it back into the engagers sweep. Switching posts-only to engagers can raise the cost, so it is refused 409 `spend_confirmation_required` until the request is re-sent with `confirmSpend: true`; switching to posts-only never needs it. A `mode` change whose current mode cannot be read is a 502 and changes nothing. UNKNOWN FIELDS ARE REFUSED: any other property is a 400 carrying `code: "unknown_field"` and naming it.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | Public identifier of the tracked company page (not a full URL and not a source id), e.g. demo-company. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
captureReplies | boolean | no | Whether this tracked source captures reply authors as leads. False skips replies before lead writes and credits. |
mode | "engagers" | "posts_only" | no | Switch what each sync of this source does (tracked_profiles.sync_mode). `engagers` sweeps the source's posts and captures the people who engaged as leads, one credit per NEW person per source. `posts_only` fetches the source's new posts only, newest first, one credit per new post up to `postsPerSync`, and writes no leads. Omit it to leave the mode unchanged. Switching from `posts_only` to `engagers` is a SPEND INCREASE: it is refused 409 `spend_confirmation_required` and nothing is written until the same request is re-sent with `confirmSpend: true`. Switching to `posts_only` can only cost less and never needs it. Binds from the next sync. |
postsPerSync | integer | no | With `mode: "posts_only"` only, and REQUIRED with it: the most posts ONE SYNC of the source may fetch, 1-60, which, because one post is one credit, is also the most it can cost in a day. Refused with `mode: "engagers"` and refused without `mode` (a body naming only `postsPerSync` reads as engagers mode and is a 400). |
confirmSpend | boolean | no | Authorises switching a posts-only source to the engagers sweep, the one change on this route that can raise what the source costs. Without it that switch is refused 409 `spend_confirmation_required`, carrying `previous` (the mode and postsPerSync in force), the requested `mode` and the source's `creditCapPerSync`, and NOTHING is changed: say the cost to the person, wait for a yes, then re-send the identical request with `confirmSpend: true`. Ignored by every other change, and not a setting on its own: a body naming only `confirmSpend` is the 400 for a request that changes nothing. |
enrichLeads | boolean | no | Whether this source's leads are enriched. Default true, what every source has always done. RAW MODE when false: this source's engagers are still captured and charged exactly as before — one credit per NEW person per source, repeats free, the same ledger and caps — but NEVER enriched: no job title, company or country, and no enrichment provider call. Those leads end enrichment status `raw` and are read with GET /api/v1/leads/raw; they never appear in GET /api/v1/leads, /engagers, exports, webhooks or integrations. A plain setting, not a spend change: the price is the same either way, so it never needs `confirmSpend`. It applies to leads captured or processed AFTER the change — leads already enriched stay enriched and raw leads stay raw. Omit it to leave the stored setting alone. |
creditCapPerSync | integer | null | no | The most enriching credits ONE SYNC of this source may spend — CAPTURE CAP COUNTS LEAD ROWS; BILLING CHARGES ONCE PER NEW PERSON PER SOURCE — or `null` for NO LIMIT, which is the state of every source nobody set one on. PER SYNC AND NOT A LIFETIME TOTAL: the source re-syncs about every 24 hours and this bounds each of those runs. PER SYNC, EVERY SYNC: it bounds EACH sync of that one person, company page or post — the capture cap counts lead rows, while billing charges once per new person per source — not the first pull only and not the life of the source, and there is NO ESTIMATE, NO CONFIRMATION GATE AND NO TEAM CEILING behind it: nothing prices a sync before you set the cap, raising or lowering it never needs `confirmSpend`, and the team's `dailyCeiling` does not count a credit of it. ⚠️ A KEYWORD SEARCH'S `creditCap` IS THE OTHER FIELD AND HAS ALL THREE: it is the daily bound on a RECURRING SWEEP, POST /api/v1/keyword/estimate prices it as `estimatedDailyMax`, `confirmSpend` gates a create or a raise with a `409 spend_confirmation_required`, and the team's `dailyCeiling` stops it — and `dailyCeiling` COUNTS KEYWORD SPEND ONLY, so no number of profile syncs can ever reach it. 0 is refused (migration 147's CHECK is `credit_cap > 0`; a source that syncs and writes nothing is a PAUSED source, which `status` already says), as are fractions and values past int4. |
Request examples
{
"creditCapPerSync": 250
}{
"creditCapPerSync": null
}{
"mode": "posts_only",
"postsPerSync": 5
}{
"mode": "engagers",
"confirmSpend": true
}{
"enrichLeads": false
}Responses
200 — The limit now in force, and the one it replaced.
| Field | Type | Required | Description |
|---|---|---|---|
sourceId | string <uuid> | no | |
type | "company" | no | |
username | string | no | The source's stored handle, exactly as GET /api/v1/sources reports it. |
creditCapPerSync | integer | null | no | The limit now stored. `null` means no limit. Per sync, every sync — the capture cap counts lead rows, while billing charges once per new person per source, with no estimate, no confirmation gate and no team ceiling: the team's `dailyCeiling` counts KEYWORD spend only. Not a keyword search's `creditCap`, which is the daily bound on a recurring sweep and has all three. |
mode | "engagers" | "posts_only" | no | Present only when the request named `mode`: the mode now stored. |
postsPerSync | integer | null | no | Present only when the request named `mode`: the posts-only bound now stored, `null` in engagers mode. |
captureReplies | boolean | no | Present only when the request named it: the reply-capture setting now stored. |
enrichLeads | boolean | no | Present only when the request named it: the raw-mode setting now stored (false = raw). |
previous | object | no | What it was before this call, so a caller can report the change without having read the source first. |
└creditCapPerSync | integer | null | no | The limit this call replaced — `null` when the source had none. Per sync, every sync — the capture cap counts lead rows, while billing charges once per new person per source, with no estimate, no confirmation gate and no team ceiling: the team's `dailyCeiling` counts KEYWORD spend only. Not a keyword search's `creditCap`, which is the daily bound on a recurring sweep and has all three. |
└mode | "engagers" | "posts_only" | no | The mode before this call. Present only when the request named `mode`. |
└postsPerSync | integer | null | no | The posts-only bound before this call, `null` in engagers mode. Present only when the request named `mode`. |
└captureReplies | boolean | no | The reply-capture setting before this call. Always present. |
└enrichLeads | boolean | no | The raw-mode setting before this call (true = enriching). Always present. |
{
"sourceId": "8f1c…",
"type": "company",
"username": "demo-company",
"creditCapPerSync": 250,
"previous": {
"creditCapPerSync": 5000
}
}| Status | Meaning | Example error |
|---|---|---|
| 400 | No setting in the body (none of `creditCapPerSync`, `mode`, `captureReplies`, `enrichLeads`), an invalid `mode`, a `postsPerSync` outside 1-60 or sent without `mode: "posts_only"`, a value outside 1–2147483647, a non-boolean `captureReplies` or `enrichLeads`, an unknown field (`unknown_field`), or the renamed `creditCap` (`renamed_field`). | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The authenticated team does not have an active subscription or an unexpired trial. | Team subscription not active |
| 404 | No such tracked company page for this team, or it has been untracked. | |
| 409 | `spend_confirmation_required`: the body switches a posts-only source to the engagers sweep without `confirmSpend: true`. Nothing was written. The body carries `error`, `code`, `previous` ({ mode, postsPerSync }), the requested `mode` and the source's `creditCapPerSync`; say the cost to the person and re-send with `confirmSpend: true` only after a yes. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 500 | Unexpected server error — the request failed on our side (e.g. a database error), not because of the request itself. On a WRITE endpoint some work may already have been applied before the failure, so prefer re-reading state over blindly retrying, or you can end up with duplicates. | Failed to list leads |
| 502 | A `mode` change could not read the source's current mode (or the write failed), so nothing was changed. Retry. |
Untrack a company page
/api/v1/company/{username}Stop tracking a LinkedIn company page. Synchronous. SOFT DELETE, the same contract as untracking a profile, a post or a keyword search: the source is DEACTIVATED (`status: "inactive"`), never removed, and nothing it captured is destroyed — `leads.tracked_profile_id` is `NOT NULL ... ON DELETE CASCADE`, so a row delete would take the captured leads with it. ACCESS IS A SEPARATE QUESTION FROM STORAGE, and this endpoint answers both differently: nothing is erased, but the leads it captured STOP BEING SERVED — GET /api/v1/leads and GET /api/v1/engagers exclude them from the all-sources view and return `404` for its `profileId`/`username`, exactly as the dashboard, its stat cards and its CSV export do, and the source stops being actionable (POST /api/v1/company/{username}/push and its `/webhook` and `/icp` routes all `404`). `GET /api/v1/sources?includeInactive=true` enumerates what you used to track; that call lists the source and does not serve its leads. THE LEADS HAVE THEIR OWN OPT-IN: pass the same parameter to the endpoints that serve them — `GET /api/v1/leads?includeInactive=true` and `GET /api/v1/engagers?includeInactive=true` — and the kept leads are returned and this `username`/`profileId` resolves instead of 404ing. It is an API-only opt-in and the default is unchanged, so the dashboard, its stat cards and its CSV export still show nothing for this source. Reactivating does: re-tracking the same username revives the source and its leads are read again. A `404` here means out of scope for that read, never that anything was erased — erasure is a support request, not an API call. Keyword searches are the one kind whose leads stay readable after untracking; see DELETE /api/v1/keyword/{id}. The sweep stops, the source leaves GET /api/v1/sources unless you pass `?includeInactive=true`, and re-tracking the same username REVIVES it. Credits already spent are never refunded. Scoped to the authenticated team; a company page the team does not track returns `404`. Trial profiles cannot be deleted (`403`) — subscribe to a paid plan to manage profiles. WHAT HAPPENS TO WORK THAT IS ALREADY RUNNING — the half this used to leave unsaid, and the half that costs money. A SWEEP ALREADY RUNNING IS STOPPED: the run asks whether its source is still tracked at every checkpoint it passes (before each provider call, before each post is harvested, before each chunk of lead rows) and abandons the run at the first checkpoint after the delete. THAT IS “AT THE NEXT CHECKPOINT”, NOT “INSTANTLY”: a provider call already in flight finishes first, and the check is coalesced behind a short window (one second by default), so expect the stop within moments rather than at the instant the delete returns. If the status read itself fails the run CARRIES ON to the next checkpoint — the control channel fails open on purpose, because abandoning a paying customer's sweep over one timed-out SELECT is the worse error. LEADS ALREADY WRITTEN ARE KEPT, and the run is finalised as `completed` with `stoppedBy: "untracked"` on GET /api/v1/sources/{id}/sync (and the username-keyed /sync routes). ⚠️ THAT VALUE IS NOT IN `lastRun.stoppedBy` on GET /api/v1/sources and never will be: that enum describes how a keyword SWEEP ended and is a closed set. QUEUED ENRICHMENT IS WRITTEN OFF AND NOT CHARGED: enrichment lands minutes to hours after capture, so the backlog is the part of an untracked source that would otherwise go on billing — every lead of this source still `pending` or `processing` is marked `skipped` with the reason `source_untracked` and costs no enriching credits. Re-tracking this source returns exactly those leads to `pending` at the start of its next sync, and returns no other skipped lead with it.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | Public identifier of the tracked company page (not a full URL), e.g. demo-company. |
Responses
200 — Company page untracked and deleted.
| Field | Type | Required | Description |
|---|---|---|---|
ok | boolean | yes | |
username | string | yes | The (normalized) public identifier of the deleted tracked profile. |
profileType | "person" | "company" | yes | |
untracked | boolean | yes | Always `true` — the tracked profile and its cascaded records were deleted. |
{
"ok": true,
"username": "demo-company",
"profileType": "company",
"untracked": true
}| Status | Meaning | Example error |
|---|---|---|
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The delete is forbidden — either the team's subscription is inactive / its trial has expired, or the profile is a trial profile (trial profiles cannot be deleted; subscribe to a paid plan to manage profiles). | Team subscription not active |
| 404 | The LinkedIn company page is not tracked by the authenticated team, or it has been untracked. | Company profile not tracked |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 500 | Unexpected server error — the request failed on our side (e.g. a database error), not because of the request itself. On a WRITE endpoint some work may already have been applied before the failure, so prefer re-reading state over blindly retrying, or you can end up with duplicates. | Failed to list leads |
Track a LinkedIn post
/api/v1/post/trackCreates a tracked POST source for the authenticated team and queues its first engagement capture. Synchronous, unlike the engagement job endpoints: the permalink is resolved against LinkedIn before anything is stored, so the call returns either a verified source or an error, never a source that cannot capture. Tracked posts have their own trial ceiling, separate from tracked profiles — a trial team may hold both. Re-tracking a post the team already has never creates a second source: an active source keeps its URL and applies any named captureReplies or creditCapPerSync setting without another capture, while an untracked source is reactivated — which queues a fresh capture and therefore charges. The status is `201` either way. A re-track of a post that has ALREADY SYNCED queues nothing and says so — `syncId: null` beside `syncNotQueuedReason` (POST /api/v1/sources/{id}/sync syncs it now) — and otherwise the body does not distinguish a duplicate from a genuine create, so re-read GET /api/v1/sources if you need to know which happened. (POST /api/v1/keyword/track, whose identity is its keywords rather than a URN, answers the same case with `200` and `resumed: true`.) WHAT YOU CAN DO WITH A TRACKED POST FROM THIS API: it is listed by GET /api/v1/sources (filter ?type=post), its engagers are returned by GET /api/v1/leads like any other source's, and it is removed by DELETE /api/v1/post/{urn}. ITS SYNC STATUS, WEBHOOK AND ICP CONFIG ARE AT /api/v1/sources/{id}/sync, /webhook and /icp — addressed by the SOURCE ID this response's source carries and GET /api/v1/sources lists, not by the URN. Use the `syncId` above with GET /api/v1/sources/{id}/sync (the source id, not the syncId) to follow the capture you just queued. The per-kind routes cannot take a post: /api/v1/{profile|company}/{username}/sync, /webhook and /icp are keyed by a LinkedIn username and a post's identifier is an activity URN, which is why these were dashboard-only until the source-id family existed. Nothing about the post source was ever the limitation — it syncs, delivers to a webhook and is ICP-filtered exactly like any other source. LARGE POSTS STOP AT THE PROVIDER'S CEILING, ABOUT 1,100 PEOPLE. Measured 29 September 2026 on a public company post declaring 3,490 reactions: the data provider served about 1,100 reactors (22 pages of ~50) and then only empty pages, while every page still declared 3,490. No paging, retry or `creditCapPerSync` gets the rest — it is not obtainable from the provider. Its sync says so instead of claiming it collected everything: GET /api/v1/sources/{id}/sync ends with `capture.stoppedBy: "provider_limit"` (and the same at the top level), and `capture.coverage` gives the declared and captured counts side by side. A post under the ceiling captures in full and still ends `exhausted`. `400` means the URL can never work and the message says what to paste instead. `503` with code `post_resolve_failed` means the post could not be verified right now and the request is worth retrying; no source is created in either case.
Authenticated with the X-API-Key header.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
captureReplies | boolean | no | Whether this tracked post captures reply authors as leads. False skips replies before lead writes and credits; omission preserves the stored setting when re-tracking.default: true |
enrichLeads | boolean | no | Whether this source's leads are enriched. Default true, what every source has always done. RAW MODE when false: this source's engagers are still captured and charged exactly as before — one credit per NEW person per source, repeats free, the same ledger and caps — but NEVER enriched: no job title, company or country, and no enrichment provider call. Those leads end enrichment status `raw` and are read with GET /api/v1/leads/raw; they never appear in GET /api/v1/leads, /engagers, exports, webhooks or integrations. A plain setting, not a spend change: the price is the same either way, so it never needs `confirmSpend`. It applies to leads captured or processed AFTER the change — leads already enriched stay enriched and raw leads stay raw. Omission preserves the stored setting when re-tracking; re-tracking an active post with it changes the setting without another capture.default: true |
creditCapPerSync | integer <int32> | no | The most credits ONE SYNC of this tracked source may spend — CAPTURE CAP COUNTS LEAD ROWS; BILLING CHARGES ONCE PER NEW PERSON PER SOURCE (tracked_profiles.credit_cap, migration 147). PER SYNC AND NOT A LIFETIME TOTAL: the source re-syncs on its own about every 24 hours and this bounds EACH of those runs. PER SYNC, EVERY SYNC: it bounds EACH sync of that one person, company page or post — the capture cap counts lead rows, while billing charges once per new person per source — not the first pull only and not the life of the source, and there is NO ESTIMATE, NO CONFIRMATION GATE AND NO TEAM CEILING behind it: nothing prices a sync before you set the cap, raising or lowering it never needs `confirmSpend`, and the team's `dailyCeiling` does not count a credit of it. ⚠️ A KEYWORD SEARCH'S `creditCap` IS THE OTHER FIELD AND HAS ALL THREE: it is the daily bound on a RECURRING SWEEP, POST /api/v1/keyword/estimate prices it as `estimatedDailyMax`, `confirmSpend` gates a create or a raise with a `409 spend_confirmation_required`, and the team's `dailyCeiling` stops it — and `dailyCeiling` COUNTS KEYWORD SPEND ONLY, so no number of profile syncs can ever reach it. ⚠ RENAMED from `creditCap` in 4.0.0 and NOT aliased: `creditCap` now names ONLY a keyword search's per-RUN cap, a different number on a different table, and sending it here is a 400 carrying `code: "renamed_field"`. A whole number from 1 to 2147483647, or `null` for NO LIMIT, which is what every source without one is. OMITTED LEAVES THE STORED VALUE ALONE; `null` CLEARS IT. It binds EVERY sync, not the first pull. AND IT IS HOW THE LIMIT IS CHANGED: calling this endpoint again for a source the team already tracks applies the value it carries, which is the only way to edit the setting over this API — the same "a second create resumes the source and applies the settings it carries" rule POST /api/v1/keyword/track follows. 0 is REFUSED rather than stored (a source that syncs and writes nothing is a PAUSED source, and `status` already says that), as are fractions and values past int4 — the worker resolves those to "no cap", so storing one would show a limit that binds nothing. |
postUrl | string | yes | URL or URN of the LinkedIn post to track. Three forms are accepted, and all three are verified against LinkedIn before the source is created: 1. `https://www.linkedin.com/feed/update/urn:li:activity:<id>` — what you get by copying a post's link from your feed. Accepted with an `activity` or `ugcPost` URN. 2. `https://www.linkedin.com/posts/<slug>-<id>-<hash>` — the permalink from a post's share menu. This form is RESOLVED against LinkedIn and the URN that comes back is what identifies the source, because the id in a permalink's slug can be a *share* id — a different number from the post's activity id. 3. `urn:li:activity:<id>` or `urn:li:ugcPost:<id>` — a bare URN on its own. A `urn:li:share:<id>` URN is REJECTED in every form, bare or /feed/update/, with a 400 telling you to paste the permalink instead. A share id cannot be mapped to its activity id without the permalink, so storing one would create a source that silently captures nothing. The `url` this source reports back from GET /api/v1/sources is stored EXACTLY AS SENT when the post is FIRST tracked — send a /feed/update/ URL and you get a /feed/update/ URL back; it is never normalised. Re-tracking a post the team already has returns the EXISTING source unchanged in identity and URL but applies any named capture setting, so its stored url keeps whichever form was used the first time even if you now send a different one. Note that the 201 body echoes the `postUrl` YOU sent, which on a duplicate is not necessarily the url stored on the source. Only the URN that identifies the post is normalised. |
Request example
{
"postUrl": "https://www.linkedin.com/posts/some-person_a-post-slug-activity-0000000000000000000-AbCd"
}Responses
201 — The post is now tracked.
| Field | Type | Required | Description |
|---|---|---|---|
captureReplies | boolean | no | Echoed only when the request named it; the reply-capture setting stored on this tracked post. |
enrichLeads | boolean | no | Echoed only when the request named it; the raw-mode setting now stored on this tracked post (false = raw: captured and charged, never enriched, read with GET /api/v1/leads/raw). |
creditCapPerSync | integer | null | no | ECHOED ONLY WHEN THE REQUEST NAMED IT — the per-sync limit now stored on this tracked post. Absent when the body carried none, which is the shape this endpoint has always returned. Per sync, every sync — the capture cap counts lead rows, while billing charges once per new person per source, with no estimate, no confirmation gate and no team ceiling: the team's `dailyCeiling` counts KEYWORD spend only. Not a keyword search's `creditCap`, which is the daily bound on a recurring sweep and has all three. |
postUrn | string | no | The RESOLVED activity URN now tracked. May differ from the id in the submitted URL. |
postUrl | string | no | The permalink as submitted, stored verbatim. |
syncId | string | null | no | Id of the first engagement capture queued for the post, or `null` when none was queued: the team's plan does not allow capture, or the post was already tracked and has synced before — re-tracking does not re-sync, and `syncNotQueuedReason` says so. POST /api/v1/sources/{id}/sync is the on-demand sync. |
syncNotQueuedReason | string | no | PRESENT ONLY when this call re-tracked a post that has already synced, beside `syncId: null`: a sentence saying no sync was queued, that the source runs on its daily schedule, and that any setting sent with the call applies from that run, and naming POST /api/v1/sources/{id}/sync, which syncs it now. Absent on a create and on a reactivation, which do queue a capture. |
| Status | Meaning | Example error |
|---|---|---|
| 400 | `postUrl` is missing, is not a LinkedIn post URL, or is a form that cannot be resolved (a bare `urn:li:share:` URN). | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The delete is forbidden — either the team's subscription is inactive / its trial has expired, or the profile is a trial profile (trial profiles cannot be deleted; subscribe to a paid plan to manage profiles). | Team subscription not active |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 500 | Unexpected server error — the request failed on our side (e.g. a database error), not because of the request itself. On a WRITE endpoint some work may already have been applied before the failure, so prefer re-reading state over blindly retrying, or you can end up with duplicates. | Failed to list leads |
| 503 | The post could not be verified against LinkedIn right now. Retryable; no source was created. Body carries `code: post_resolve_failed`. |
Untrack a LinkedIn post
/api/v1/post/{urn}Stop tracking a post and stop capturing its engagement. The delete half of the pair with POST /api/v1/post/track. Synchronous. SOFT DELETE, like untracking a profile: the source is deactivated, never removed, because leads reference it and a row delete would cascade the captured leads away with it. The post stops syncing and leaves the tracked-source list. Its captured leads are RETAINED in your account but stop being SERVED: GET /api/v1/leads excludes an untracked post's leads from the all-sources view and returns 404 for its id BY DEFAULT, which is what the dashboard shows too. `GET /api/v1/sources?includeInactive=true` still lists the post, with `status: "inactive"` — and `GET /api/v1/leads?includeInactive=true` / `GET /api/v1/engagers?includeInactive=true` read the kept leads back and make that id resolve. That opt-in exists on the API only; the dashboard has no equivalent switch, so its own view is unchanged. Keyword searches are the one kind whose leads stay readable after untracking — see DELETE /api/v1/keyword/{id}. Untracking a post that is already untracked returns `404`. Identify the post by the URN that GET /api/v1/sources returns for it (`urn:li:activity:…` or `urn:li:ugcPost:…`) — the same value POST /api/v1/post/track echoes back as `postUrn`. The colons are legal in a path segment, so both the raw and percent-encoded forms work. A tracked post's sync status, webhook config and ICP config are reached by its SOURCE ID at /api/v1/sources/{id}/sync, /webhook and /icp — not by this URN; see POST /api/v1/post/track. `403` for a post tracked during a free trial: trial sources cannot be deleted on any surface, the dashboard included, until the team subscribes. WHAT HAPPENS TO WORK THAT IS ALREADY RUNNING — the half this used to leave unsaid, and the half that costs money. A SWEEP ALREADY RUNNING IS STOPPED: the run asks whether its source is still tracked at every checkpoint it passes (before each provider call, before each post is harvested, before each chunk of lead rows) and abandons the run at the first checkpoint after the delete. THAT IS “AT THE NEXT CHECKPOINT”, NOT “INSTANTLY”: a provider call already in flight finishes first, and the check is coalesced behind a short window (one second by default), so expect the stop within moments rather than at the instant the delete returns. If the status read itself fails the run CARRIES ON to the next checkpoint — the control channel fails open on purpose, because abandoning a paying customer's sweep over one timed-out SELECT is the worse error. LEADS ALREADY WRITTEN ARE KEPT, and the run is finalised as `completed` with `stoppedBy: "untracked"` on GET /api/v1/sources/{id}/sync (and the username-keyed /sync routes). ⚠️ THAT VALUE IS NOT IN `lastRun.stoppedBy` on GET /api/v1/sources and never will be: that enum describes how a keyword SWEEP ended and is a closed set. QUEUED ENRICHMENT IS WRITTEN OFF AND NOT CHARGED: enrichment lands minutes to hours after capture, so the backlog is the part of an untracked source that would otherwise go on billing — every lead of this source still `pending` or `processing` is marked `skipped` with the reason `source_untracked` and costs no enriching credits. Re-tracking this source returns exactly those leads to `pending` at the start of its next sync, and returns no other skipped lead with it.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
urn | string | yes | URN of the tracked post, e.g. urn:li:activity:7123456789012345678. Percent-encoding is optional. |
Responses
200 — Post untracked. The source is deactivated; its captured leads are kept.
| Field | Type | Required | Description |
|---|---|---|---|
ok | boolean | yes | |
username | string | yes | The (normalized) public identifier of the deleted tracked profile. |
profileType | "person" | "company" | yes | |
untracked | boolean | yes | Always `true` — the tracked profile and its cascaded records were deleted. |
{
"ok": true,
"username": "urn:li:activity:7123456789012345678",
"profileType": "post",
"untracked": true
}| Status | Meaning | Example error |
|---|---|---|
| 400 | The path segment is not a post URN. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The delete is forbidden — either the team's subscription is inactive / its trial has expired, or the profile is a trial profile (trial profiles cannot be deleted; subscribe to a paid plan to manage profiles). | Team subscription not active |
| 404 | No active tracked post with that URN for this team. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 500 | Unexpected server error — the request failed on our side (e.g. a database error), not because of the request itself. On a WRITE endpoint some work may already have been applied before the failure, so prefer re-reading state over blindly retrying, or you can end up with duplicates. | Failed to list leads |
Estimate a keyword search (dry run)
/api/v1/keyword/estimatePRICES A KEYWORD SEARCH WITHOUT CREATING IT — the call to make BEFORE POST /api/v1/keyword/track. WHY IT EXISTS. The 409 `spend_confirmation_required` already carries the estimate, and it is a workable way to obtain one — but it makes "show me the cost" AN ERROR PATH. A CLI has to print a refusal in order to answer a question, and an agent written the ordinary way treats every non-2xx as a failure and never reads the body: the MCP layer collapses a failed tool result to `{ statusCode, error }`, so the numbers in that 409 reach no agent at all. This answers 200 with the same object, so they do. The 409 is unchanged and remains the ENFORCEMENT; this is the intended first call. IT CREATES NOTHING, RESUMES NOTHING AND CHARGES NOTHING. No source row, no search row, no status change on a search you already have, no queued sweep, no credit. It performs two reads and returns. THE SAME BODY AS THE CREATE, WITH THE SAME 400s. Every field POST /api/v1/keyword/track accepts is accepted here and validated by the same code in the same order, so a body this endpoint prices is a body that endpoint would accept. An estimate that accepted what the create refuses would be answering a question about a search that cannot exist. THE CONTRACT'S TWO GATES DO NOT APPLY HERE, deliberately. `scope_required` and `spend_confirmation_required` are about authorising a recurring charge, and this endpoint creates none — so the four scope fields are OPTIONAL here even after the cut-over, and `confirmSpend` is accepted (validated for shape, as everywhere) and does nothing. Requiring them would mean choosing the scope in order to be told what the scope costs, which is the question. The caps are still RESOLVED exactly as the create resolves them — sent wins, omitted inherits from the live search, else the create-time default — which is what makes this `estimatedDailyMax` and the subsequent 201's the same number. RESUMED. `resumed: true` means these terms already name a search this team has (ACTIVE or one you DELETED), so the create that follows would return THAT search, apply the settings you named and leave the rest alone — and, if it had been deleted, track it again and queue a charging sweep within seconds. `previous` then carries that search's CURRENT caps beside the requested ones, under the same key and with the same meaning the 409 gives it. THE FORMULA behind `estimatedDailyMax` and `daysToExhaustAtCap` is written out once, on POST /api/v1/keyword/track. Both keys are OMITTED here, never null and never 0, when there is no honest number — test for the KEY.
Authenticated with the X-API-Key header.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | What this search is CALLED in your source list, e.g. "hiring an SDR". Optional: omit it and the search is titled by its keywords, which is what every search created before this field existed shows. Blank is rejected rather than treated as absent — omit the field instead. The name is a label only: the KEYWORDS remain the search's identity, so two searches may share a name but not a term-set. |
captureReplies | boolean | no | Whether this tracked search captures reply authors as leads. False skips replies before lead writes and credits.default: true |
enrichLeads | boolean | no | Whether this source's leads are enriched. Default true, what every source has always done. RAW MODE when false: this source's engagers are still captured and charged exactly as before — one credit per NEW person per source, repeats free, the same ledger and caps — but NEVER enriched: no job title, company or country, and no enrichment provider call. Those leads end enrichment status `raw` and are read with GET /api/v1/leads/raw; they never appear in GET /api/v1/leads, /engagers, exports, webhooks or integrations. A plain setting, not a spend change: the price is the same either way, so it never needs `confirmSpend`. It applies to leads captured or processed AFTER the change — leads already enriched stay enriched and raw leads stay raw. Stored on the search's tracked source. A resume (the same keywords again) with it changes the setting; omitting it leaves the stored value alone. It does not change the estimate: raw mode costs what enrichment costs.default: true |
keywords | string[] | no | One to ten search terms. EACH IS A SEPARATE PROVIDER CALL per run — the underlying search takes a single term and supports no OR syntax — and the results are merged and deduplicated by post URN, so a post two terms both find is captured once. The run's scan (at most 2,000 posts, an internal bound) is SHARED across the terms and allocated round-robin, so a high-volume term cannot crowd out the others; a term that runs out early yields its share to the rest. Terms must be unique, and a term containing a parenthesis is a 400 (the search provider refuses one). OPTIONAL SINCE THE BOOLEAN EXPRESSION FIELD: send `keywords` or `expression`, never both. A plain list is identical to the same terms joined with OR. A term wrapped in double quotes (straight or curly) is an EXACT PHRASE, as it is in `expression`: `"outbound playbook"` keeps only posts whose text has those words next to each other and in order, and discards the rest with the reason `missing phrase "outbound playbook"`. It is stored in straight quotes. An unquoted multi-word term stays a broad match. |
keyword | string | no | Convenience alias for a single-term search. Accepted as a one-element `keywords`. Prefer `keywords`. |
expression | string | no | A boolean expression INSTEAD OF a term list — `hiring AND "sales ops" NOT recruiter OR fundraising`. Exactly one of `keywords` or `expression` may be sent; both together is a 400, neither is a 400. GRAMMAR: NOT binds tighter than AND, which binds tighter than OR, and there are NO PARENTHESES — `a AND b OR c` means `(a AND b) OR c`. A PARENTHESIS IS A 400 that explains this precedence, with ONE exception: a search's own canonical `expression` (as the create reply and GET /api/v1/sources return it, e.g. `(hiring AND NOT recruiter) OR fundraising`) may be sent back UNCHANGED and compiles to the same search, so a search can be recreated from what it reports. Anything else bracketed — `(a OR b) AND c`, `NOT (a AND b)`, a stray `(` — is refused rather than guessed at, and NO TERM MAY CONTAIN A PARENTHESIS, even in quotes: the search provider refuses one. Operators are UPPER CASE ONLY: a lower-case `and` is an ordinary search term, which is what stops an existing term like `sales and marketing` changing meaning. Two terms with no operator between them means OR, which is exactly what a `keywords` list has always meant — so `{"expression": "a OR b"}` and `{"keywords": ["a","b"]}` create the identical search. A quoted phrase is one term; curly quotes count. AND A QUOTED PHRASE IS MATCHED AS A PHRASE in every expression shape, including a lone term and an OR-only branch. Its words must appear next to each other in order, ignoring case and allowing punctuation between them; a post with sales, finance, ops fails the quoted sales ops check. LinkedIn still receives one provider search for each required term and may return broader candidates, which the worker discards before the run's scan ceiling is applied. Unquoted terms and legacy keyword-list chips keep their broad provider matching. COST: only OR is served by the search itself — each required term is one provider call per run, the same as a `keywords` list of the same terms. AND and NOT CANNOT be expressed upstream (the provider takes one `keyword` string and documents no boolean syntax) and are applied afterwards by reading each post's own text, so a narrow expression spends the same provider searches and keeps fewer posts. The run applies them BEFORE its scan ceiling is allocated, so that ceiling counts posts that survived, and credits are only charged at capture — a discarded post costs no credits and IS marked as seen, so the next run does not re-harvest it. WHAT WAS DISCARDED IS REPORTED, not merely counted: `lastRun.discardedByExpression` (and its older name `lastRun.postsFilteredOut`) is how many, those posts are counted in `lastRun.postsScanned` and not in `lastRun.postsKept`, and GET /api/v1/sources/{id}/kept-posts?include=swept returns a row per discarded post carrying `outcome: "discarded"` and a `reason` naming the failing part of the expression, e.g. `missing phrase "sales ops"` or `contains recruiter`. The expression COMPILES to `keywords`: its required terms, de-duplicated, in first-appearance order, and that is what the reply and GET /api/v1/sources report. A malformed expression is a 400 at SAVE TIME, naming the token at fault; it is never stored. Max 10 required terms, 60 tokens, 1000 characters. CREATE-ONLY: PATCH /api/v1/keyword/{id} refuses it, because the seen-set is keyed to the SEARCH and a new expression would inherit the posts the old one rejected. |
sort | "RELEVANCE" | "DATE_POSTED" | no | ⚠️ Stored and reported back, but every keyword search currently RUNS BY RELEVANCE whatever this holds (since 5 October 2026: newest-first returned nothing for high-volume terms).default: "DATE_POSTED" |
datePosted | "PAST_24_HOURS" | "PAST_WEEK" | "PAST_MONTH" | no | How far back each sweep looks. PAST_MONTH is the provider's widest window.default: "PAST_WEEK" |
contentType | "VIDEO" | "IMAGE" | "JOB" | "LIVE_VIDEO" | "DOCUMENT" | "COLLABORATIVE_ARTICLE" | no | Omit for no content-type filter. |
authorIndustry | string[] | no | Keeps only posts whose author is in one of these industries. Applied by LinkedIn's own search, so the run's scan is spent on matching posts rather than on posts discarded afterwards. A NUMERIC LinkedIn industry id. Send it bare (`96`) or wrapped (`urn:li:industry:96`); both are accepted and stored as the wrapped form. An industry NAME is not an id. FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN — `"jasonlemkin"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run. |
authorCompany | string[] | no | Keeps only posts whose author currently works at one of these companies. A NUMERIC LinkedIn organisation id. Send it bare (`1441`) or wrapped (`urn:li:organization:1441`); both are accepted and stored as the wrapped form. A company NAME or slug is not an id. FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN — `"jasonlemkin"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run. |
authorKeyword | string[] | no | Plain words matched against the AUTHOR (headline, job title), not the post text. The only filter in this group that takes words rather than URNs. |
fromPerson | string[] | no | Keeps only posts written by these people. A LinkedIn MEMBER ID — `"AC"` followed by base64url characters, about 39 in all. Send it bare (`ACoAAA5eqCEBzmkNfjyOp9_MseBpRQ-P17SuIos`) or wrapped (`urn:li:person:ACoAAA5eqCEBzmkNfjyOp9_MseBpRQ-P17SuIos`); both are accepted and stored as the wrapped form, so `GET /api/v1/sources` reports one spelling whichever you sent. This is the SAME id profile enrichment returns as `entityUrn`. No member id to hand? `GET /api/v1/profile/{username}/urn` resolves any public handle for free — no enrichment, no tracking, `creditsCharged: 0` (CLI `profile-urn`, MCP `get_profile_urn`). FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN — `"jasonlemkin"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run. |
fromCompany | string[] | no | Keeps only posts published by these company pages. A NUMERIC LinkedIn organisation id. Send it bare (`1441`) or wrapped (`urn:li:organization:1441`); both are accepted and stored as the wrapped form. A company NAME or slug is not an id. FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN — `"jasonlemkin"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run. |
mentionsPerson | string[] | no | Keeps only posts that @mention one of these people. A LinkedIn MEMBER ID — `"AC"` followed by base64url characters, about 39 in all. Send it bare (`ACoAAA5eqCEBzmkNfjyOp9_MseBpRQ-P17SuIos`) or wrapped (`urn:li:person:ACoAAA5eqCEBzmkNfjyOp9_MseBpRQ-P17SuIos`); both are accepted and stored as the wrapped form, so `GET /api/v1/sources` reports one spelling whichever you sent. This is the SAME id profile enrichment returns as `entityUrn`. No member id to hand? `GET /api/v1/profile/{username}/urn` resolves any public handle for free — no enrichment, no tracking, `creditsCharged: 0` (CLI `profile-urn`, MCP `get_profile_urn`). FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN — `"jasonlemkin"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run. |
mentionsCompany | string[] | no | Keeps only posts that @mention one of these company pages. A NUMERIC LinkedIn organisation id. Send it bare (`1441`) or wrapped (`urn:li:organization:1441`); both are accepted and stored as the wrapped form. A company NAME or slug is not an id. FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN — `"jasonlemkin"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run. |
aiProvider | "openai" | "grok" | "gemini" | "claude" | no | WHICH stored credential filters the posts. The KEY ITSELF IS NOT ACCEPTED HERE and cannot be supplied through this API — save it once in the dashboard's AI filtering panel on Keyword Engagement, where it is held in an encrypted vault, one per provider per team. That panel also lists which providers your team already has a key for. Omit for no AI filter: every new post found is captured. |
aiModel | string | no | Model id for the chosen provider. OPTIONAL EVEN WHEN aiProvider IS SET — this field is not required and never has been; a create carrying aiProvider and aiPrompt without it is a 201. Omit it and the provider's DEFAULT MODEL is used: openai `gpt-6-luna`, grok `grok-4.3`, gemini `gemini-3.5-flash-lite`, claude `claude-haiku-4-5-20251001`. Those are a cheap, fast current model from each vendor for what the filter actually does (one keep/reject decision against your criterion), and they MOVE as vendors retire models — name a model here only when you want one pinned against that. A blank string is treated as unset, the same way the filter itself treats it. A model sent with NO aiProvider is a 400: there is no filter for it to configure, and storing it would leave a setting that never runs. |
aiPrompt | string | no | Your criterion, e.g. "posts where someone is hiring engineers". Required when aiProvider is set. EVERY POST A RUN SCANS IS SENT TO YOUR OWN AI PROVIDER, on your key and billed by that provider rather than in Cornersight credits: up to 2,000 posts in each run (the run's internal scan ceiling), 10 to a call — about 200 calls at most — or one call per post when the model's batch answer cannot be read. A post already judged under the same prompt is not sent again. `creditCap` does not bound this bill; it bounds Cornersight credits. |
postBudget | integer | no | RETIRED on 30 September 2026 and IGNORED. It was the per-run post limit; a keyword search now has exactly one limit you set, `creditCap`, and a run collects until it has spent it. Accepted with any value, on create, estimate and resume and under either `contractVersion`, so an older client is never refused for sending it — it changes nothing, is no longer a scope field the contract requires, and the reply lists it in `ignoredFields`. A `posts_only` search's own limit is `postsPerSync`, which is unaffected. |
creditCap | integer | no | Maximum enriching credits one run may spend, and the real limit on what a sweep costs — one credit per new person for this search; repeat engagements are free. PER RUN: the search repeats about every 24 hours, so a creditCap of N is up to N credits every day until the search is untracked. Default 100, and deliberately unbounded above — no PRODUCT ceiling is imposed, because this bounds a quantity you are paying for rather than one we define. The declared `maximum` of 2147483647 is a STORAGE bound, not a recommendation: it is the largest value the column holds, and a cap anywhere near it would be a spending authorisation renewed every day. WHAT BOUNDS THE SERIES is not this field but your team's monthly enriching-credit balance: a sweep is skipped entirely when the balance is exhausted, so a recurring search stops on its own rather than running forever. IT ALSO BOUNDS PROVIDER PAGES, not just rows written — the remaining allowance is what decides how many pages of reactions and comments are fetched per post, so a high cap costs provider calls as well as storage. The provider exposes no quota of its own, so this is the only spend control on a sweep.default: 100 |
mode | "engagers" | "posts_only" | no | engagers captures leads; posts_only saves matching post text without people or leads, charging one credit per new kept post.default: "engagers" |
postsPerSync | integer | no | Required with posts_only. Maximum new posts to charge in each daily run (1-60); repeats and rejected posts are free. |
captureEngagers | boolean | no | Capture the people who ENGAGED with each kept post — likes and comments. Default true, which is what every keyword search has always done. false fetches no reactions or comments at all (no provider calls for them) and requires `capturePostAuthors: true`: a search must capture someone (400 `no_capture_target` otherwise). Engagers mode only — sent with `mode: "posts_only"` it is a 400. Omitted on a resume or PATCH leaves the stored value alone. Turning it back ON for a search that captured post authors only needs `confirmSpend: true` (409 `spend_confirmation_required` otherwise): the daily figure is `creditCap` either way, but authors only can charge at most one new person per kept post, while engagers can spend the whole cap on one busy post.default: true |
capturePostAuthors | boolean | no | Capture the person who WROTE each kept post, as a lead with `engagementType: "Author"`. Default false. Read from the keyword search result itself, so it costs no extra provider call. Charged exactly like an engager: ONE credit per NEW person for this search; the same person again — on a later post, or also as a liker or commenter of the same post — is a free repeat (the two rows are kept, the person is charged once). A post whose author is a COMPANY PAGE captures no author and costs nothing; the run reports how many as `lastRun.companyAuthorsSkipped`. The credit cap, the team's daily keyword ceiling and a trial source's lead cap bind authors exactly as they bind engagers. With `captureEngagers: false` a run adds at most one new person per kept post, and `estimatedDailyMax` is still `creditCap` — the one limit you set. Engagers mode only.default: false |
captureMode | "depth" | "breadth" | no | How creditCap is spent across a run's posts. `depth` (the default, and the behaviour of every search created before this field) hands each post the whole remaining cap, so the run goes deep on the posts it reaches first — one high-engagement post can consume the entire cap and leave later posts uncaptured that run. `breadth` shares the cap across the run's posts by their engagement counts and redistributes the unspent remainder to posts that can absorb it, so the run fans across more posts with fewer engagers from each. Which engagers a capped post contributes is the provider's own order (reactions before comments), not most-recent or most-relevant.default: "depth" |
maxEngagementsPerPost | integer | no | Breadth mode only: an explicit per-post ceiling — capture at most this many engagements from any one post. Omit for no ceiling (the default: breadth fair-shares the whole creditCap across posts). `creditCap` remains the HARD spend limit and this only shapes distribution beneath it: if maxEngagementsPerPost times the posts a run reaches exceeds creditCap the cap still binds and later posts go uncaptured; if it is below creditCap the run spends less than the cap, on purpose — a deliberately narrower, more even sweep. Ignored in depth mode. The declared `maximum` is the storage bound (int4), not a product ceiling — breadth has no useful reason to approach it. |
runOnce | boolean | no | Harvest ONCE, then stop scheduling. Default false, which is the unbounded daily cadence every keyword search has always had. A run that FAILED does not satisfy it — see `maxRuns` for what counts as a run — so this means one harvest, not one attempt. Equivalent to `maxRuns: 1` and checked before it; setting both is legal and the tighter binds. A search stopped this way keeps its leads and stays in your source list; `schedule.stoppedAt` on GET /api/v1/sources says when, and `schedule.stoppedReason` says which control did it.default: false |
endAt | string <date-time> | no | A UTC instant after which this search stops scheduling. Must be in the FUTURE — a date already past is a 400, because it would create a search that is parked before it ever runs. Checked before each run as well as after it, so a search whose end passed while it was idle never sweeps again, including from a manual sync. Omit for no end date (the default); send `null` to clear one. Stored normalised to UTC. |
maxRuns | integer | no | Stop after this many COUNTED runs. A run COUNTS when it reached the provider and ended ordinarily (`exhausted`, `credits`, `post_limit`, or a `team_cap`/`lead_cap` that bound it mid-sweep). It does NOT count when the run FAILED (`error`, `ai_error`), when an untrack abandoned it, or when it was skipped before the provider was asked (a spent team ceiling or a full lead cap — both report `postsScanned: 0`). A sweep that ran and captured nobody DOES count. Raising this above the runs already completed RESTARTS a search that stopped at it — PATCH /api/v1/keyword/{id} clears the stop and queues the search again in the same call. Omit for no run budget (the default); send `null` to clear one. |
confirmChanges | boolean | no | ACKNOWLEDGE A REWRITE, as `confirmSpend` acknowledges a charge. Only ever relevant on a RESUME: these keywords already name a live search and this body carries a DIFFERENT value for a targeting filter, `sort`, `datePosted`, `contentType` or the AI filter. A resume applies every setting the call carries and leaves the rest alone, and it used to do so in silence — create with `fromPerson` A, then with `fromPerson` B, and the reply was 200 `resumed: true` with no `previous`, no `changed` and nothing to notice. Without this field such a call is now a `409` `settings_conflict` and NOTHING is changed. `confirmSpend` does NOT acknowledge a rewrite, and `confirmChanges` does not authorise a cap rise — two changes, two fields — so a body that does both carries both. It was accepted for both once, and that made the refusal unreachable from the CLI (`track-keyword` requires `--confirm-spend`, so every create it sent arrived pre-acknowledged and rewrote live searches in silence) and from any caller that sent it early. The pair still costs ONE round trip: a `spend_confirmation_required` refusal on a body that would also rewrite something carries `changed` and names it. Ignored when nothing would change. Accepted and inert on POST /api/v1/keyword/estimate, which changes nothing to acknowledge. |
confirmSpend | boolean | no | THE SPEND CONFIRMATION, and the one field that is about consent rather than configuration. `true` means the person who asked for this search has been shown what one day of it can cost and has agreed to it. THE THRESHOLD IS EVERY CREATE — there is no credit figure below which it is skipped — because a keyword search is a RECURRING DAILY CHARGE whose only upper bound is your team's monthly balance, and the thing being authorised is the commitment, not an amount. It is required on a create, on a RESUME (keywords matching an existing search re-cap THAT search rather than making a second one) and on any update that RAISES what one day can cost (`creditCap`, or `postsPerSync` on a `posts_only` search). Without it the request is refused `409` with code `spend_confirmation_required`, whose body carries `creditCap`, `captureMode`, `datePosted`, `estimatedDailyMax`, `remainingBalance` and `daysToExhaustAtCap` — the numbers the dashboard puts above its Confirm button. Show them to the person, then re-send the identical request with this field added. DURING THE WARNING PHASE (see `contractVersion`) an omitted `confirmSpend` still returns 201/200, with a `deprecations` entry naming it and the cut-over date after which it will not. ⚠️ THE CUT-OVER IS SCHEDULED FOR 2026-11-01, AND THIS IS THE NOTICE OF IT. UNTIL 2026-11-01 an old-style create — one that omits the three scope fields and `confirmSpend` — still returns 201/200 and carries a `deprecations` entry naming exactly what to add. FROM 2026-11-01 the same request is refused: a missing scope field is `400` `scope_required` naming the field, and a scope that is stated but not confirmed is `409` `spend_confirmation_required` — the same refusal a request pinned to `contractVersion: "2026-11-01"` already gets today, which is how you test the new behaviour before the date. THE EXACT CHANGE IS FOUR KEYS: add "creditCap": 100, "captureMode": "depth", "datePosted": "PAST_WEEK" and "confirmSpend": true to the request body. Those three values are what the dashboard's "Search scope" step pre-fills, offered so you can copy them DELIBERATELY — they are NOT server-side defaults and nothing is chosen for you, so send the caps you actually want, and send `confirmSpend` only once the person has heard the daily figure. THE VERSION STRING AND THE CUT-OVER DATE ARE THE SAME DAY, which they did not have to be: the version is what you PIN, the date is what happens to you if you do not. |
contractVersion | "2026-09-09" | "2026-11-01" | no | WHICH REVISION OF THIS ENDPOINT'S CONTRACT TO BE HELD TO, so an integration can move on its own schedule instead of on ours. `2026-11-01` is the spend contract: `creditCap`, `captureMode` and `datePosted` all required, and `confirmSpend: true` required — send it and those rules apply to this request immediately, whichever phase the server is in. REVISED ON 30 SEPTEMBER 2026, BEFORE ITS CUT-OVER: it named a fourth required field, `postBudget`, which left the contract when the per-run post limit was retired. The version string did not change because the revision only removes a requirement — nothing that was accepted before is refused now — and a request that still sends `postBudget`, pinned or not, is accepted with the value ignored and listed in the reply's `ignoredFields`. `2026-09-09` DECLARES the older contract (no scope fields, no confirmation), which is what an omitted `contractVersion` also gets, and is accepted only until the cut-over; after it, a request pinned to the old version is refused like any other and the message says the pin is why. ANY OTHER VALUE IS A `400` with code `invalid_contract_version`, in both phases — a misspelled opt-in that was silently ignored would mean believing you had moved when you had not. THE ROLL-OUT IN ONE SENTENCE: today an old-style create still succeeds and carries `deprecations` telling you exactly what to add and by when; from the cut-over the same request is a `400` (`scope_required`) or a `409` (`spend_confirmation_required`). The cut-over instant is published on every `deprecations` entry as `cutoverAt`, and is `null` there while it is unscheduled. ⚠️ THE CUT-OVER IS SCHEDULED FOR 2026-11-01, AND THIS IS THE NOTICE OF IT. UNTIL 2026-11-01 an old-style create — one that omits the three scope fields and `confirmSpend` — still returns 201/200 and carries a `deprecations` entry naming exactly what to add. FROM 2026-11-01 the same request is refused: a missing scope field is `400` `scope_required` naming the field, and a scope that is stated but not confirmed is `409` `spend_confirmation_required` — the same refusal a request pinned to `contractVersion: "2026-11-01"` already gets today, which is how you test the new behaviour before the date. THE EXACT CHANGE IS FOUR KEYS: add "creditCap": 100, "captureMode": "depth", "datePosted": "PAST_WEEK" and "confirmSpend": true to the request body. Those three values are what the dashboard's "Search scope" step pre-fills, offered so you can copy them DELIBERATELY — they are NOT server-side defaults and nothing is chosen for you, so send the caps you actually want, and send `confirmSpend` only once the person has heard the daily figure. THE VERSION STRING AND THE CUT-OVER DATE ARE THE SAME DAY, which they did not have to be: the version is what you PIN, the date is what happens to you if you do not. The date above is the published schedule; `cutoverAt` carries it as an instant once this deployment has been configured with it, and is `null` until then — so read the date from this documentation and treat `null` as "not configured here yet" rather than as "not happening". |
Request examples
{
"keywords": [
"AI agents"
]
}{
"keywords": [
"AI agents",
"LLM evals"
],
"datePosted": "PAST_WEEK",
"captureMode": "breadth",
"creditCap": 500
}Responses
200 — The estimate. NOTHING WAS CREATED, RESUMED OR CHARGED. `remainingBalance`, `estimatedDailyMax` and `daysToExhaustAtCap` are OMITTED rather than nulled when there is no honest number, and `previous` is present only on a resume — test for the KEY. `previous` IS THE SEARCH'S WHOLE CURRENT SETTINGS, not only its four caps — those four keys are unchanged in name and meaning, and every other setting a resume could overwrite now sits beside them, which is the half that was being overwritten silently. `changed` lists what POST /api/v1/keyword/track would REWRITE on that search, each entry `{ field, from, to }`, and `settings` is what it would become; both are OMITTED entirely when nothing would change, so test for the KEY. When `changed` is present, the same body sent to the create is a `409` `settings_conflict` unless it carries `confirmChanges` — `confirmSpend` authorises the charge only and does not acknowledge a rewrite.
| Field | Type | Required | Description |
|---|---|---|---|
matchLogic | string | no | Present only when `keywords` has more than one term and no `expression` was sent — the plain-list OR shape. Says plainly that this search matches ANY of these terms, not all of them together, and names the `expression` syntax to use instead if that is not what was wanted. Absent for a single term and for any `expression`: typing AND, OR or NOT is itself the acknowledgement, so this never second-guesses a caller who already named the logic. Identical to what POST /api/v1/keyword/track would report for the same body — estimate_keyword_search is the FIRST call track_keyword's own description tells an agent to make, so this is where it matters most, before anything is spent. |
creditCap | integer | no | Maximum enriching credits one run could spend, resolved the same way. PER RUN, and the sweep repeats about every 24 hours. |
captureMode | "depth" | "breadth" | no | How the cap would be spent across a run's posts, resolved the same way. |
datePosted | "PAST_24_HOURS" | "PAST_WEEK" | "PAST_MONTH" | no | How far back each sweep would look, resolved the same way. |
captureEngagers | boolean | no | Whether the search captures people who liked or commented (migration 176). Null on a `posts_only` search, which captures no people. |
capturePostAuthors | boolean | no | Whether the search captures each kept post's author as an `Author` lead (migration 176). Null on a `posts_only` search, which captures no people. |
resumed | boolean | no | true when these terms already name a search this team has, so POST /api/v1/keyword/track would RESUME it rather than create a second one — applying the settings you named, leaving the rest alone, and re-tracking it with a charging sweep if it had been deleted. Always present. |
previous | object | no | The live search's CURRENT caps, present ONLY when `resumed` is true — there is nothing for a genuine create to be measured against. The same key, the same shape and the same meaning the 409 gives it: what the search is bound by BEFORE this body would be applied. |
└creditCap | integer | no | |
└captureMode | string | no | |
└datePosted | string | no | |
remainingBalance | integer | no | Your team's remaining enriching credits. OMITTED when the balance could not be read. |
estimatedDailyMax | integer | no | The most ONE DAY of this search could cost in enriching credits — the same figure the create returns and the 409 quotes, from the same shared function. OMITTED, never null and never 0, when there is no usable cap. For a `posts_only` body it is `min(postsPerSync, creditCap)` — the figure the create's 201/200, its 409 and PATCH quote for the same settings. The formula is written out on POST /api/v1/keyword/track. |
daysToExhaustAtCap | integer | no | WHOLE days your remaining balance funds at that rate. OMITTED, never null, when there is no rate or no readable balance — while a value of 0 is a real answer and the alarming one: the balance cannot fund one whole day, so the first sweep would be cut short. |
ignoredFields | "postBudget"[] | no | The fields this request sent that NOTHING READS ANY MORE, so a setting you still send is never dropped in silence. Today that can only be `postBudget` — the per-run post limit, retired on 30 September 2026: accepted with any value, applied to nothing, and named here. ABSENT when the request sent none. |
filtering | object | no | Whether this search WOULD filter the posts it finds, resolved as the search will stand — sent wins, omitted inherits from the live search — so a bare estimate against an AI-filtered search reports `applied: true`. Unlike the create's success body this one DOES carry `suggestion` when `applied` is false, because this is the call you make while you are looking at the cost, which is the moment the 409 makes the same offer. It is never a gate: an unfiltered search is priced and created exactly as it always was. |
└applied | boolean | no | True when at least one targeting filter, or a complete AI filter (provider AND a non-blank prompt), would be in force. Always present. |
└suggestion | string | no | One sentence, written for the person, offering the filters this team could actually use today. Present ONLY when `applied` is false. |
└aiKeyStoredFor | string[] | no | Which of `openai`, `grok`, `gemini` and `claude` this team has a key stored for — sorted, de-duplicated, possibly empty. Answered from the credential's provider column; no key is ever decrypted or returned. |
| Status | Meaning | Example error |
|---|---|---|
| 400 | The same 400s POST /api/v1/keyword/track returns for the same body, in the same order and with the same messages: `keywords` missing, empty, longer than ten terms, duplicated or over-long; `aiProvider`/`aiPrompt` not supplied together; code `invalid_enum_value` for `sort`, `datePosted`, `contentType`, `captureMode` or `aiProvider`; code `invalid_number` for `creditCap` or `maxEngagementsPerPost` outside its declared range; code `invalid_contract_version` for an unknown `contractVersion`. NOT returned for a missing scope field — `scope_required` is the create's gate and does not apply to a call that creates nothing. Also returned with code `invalid_urn` when a value in `authorIndustry`, `authorCompany`, `fromPerson`, `fromCompany`, `mentionsPerson` or `mentionsCompany` is not an id of the right shape. The body carries `field`, `value`, `expected` and `example` beside the message, so the fix needs no sentence parsing. A HANDLE IS NOT A URN: `fromPerson: ["jasonlemkin"]` used to be accepted and then failed at the provider on EVERY daily run, reported as a retryable outage. Resolve a handle for free with `GET /api/v1/profile/{username}/urn`. `authorKeyword` is plain words and is NOT checked. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 500 | Unexpected server error — the request failed on our side (e.g. a database error), not because of the request itself. On a WRITE endpoint some work may already have been applied before the failure, so prefer re-reading state over blindly retrying, or you can end up with duplicates. | Failed to list leads |
| 502 | The current keyword search could not be verified because a database read failed. No estimate is returned. Nothing is created, resumed, queued, changed or charged; retry when the database is available. |
Track a keyword search
/api/v1/keyword/trackCreates a KEYWORD SEARCH: a recurring sweep that finds LinkedIn posts by ONE OR MORE keywords (up to ten), optionally filters them with your own AI model against a criterion you write, and captures the engagers of the survivors as leads — or, with `capturePostAuthors: true`, the person who WROTE each surviving post as an `Author` lead, beside the engagers or (`captureEngagers: false`) instead of them. An author is charged like an engager: one credit per NEW person for the search, repeats free, company-page authors skipped and never charged. It becomes a tracked source like a profile or a post — listed by GET /api/v1/sources with type `keyword`, its leads returned by GET /api/v1/leads. SYNCHRONOUS, and unlike POST /api/v1/post/track there is nothing to verify: a post must be resolved against LinkedIn before it can be stored, but a keyword search performs no provider call at creation — the sweep happens later. This returns the created source, not a jobId. The first sweep is queued immediately; poll its progress with GET /api/v1/{profile|company}/{username}/sync's sibling on the dashboard, or simply read the leads as they arrive. THE AI KEY IS NOT ACCEPTED BY THIS ENDPOINT and cannot be supplied through this API at all. Save it once in the dashboard's AI filtering panel, on Keyword Engagement — the same panel that sets the provider and prompt. It is held in an encrypted vault, one key per provider per team, and never returned by any endpoint; the panel lists which providers your team has a key for so you can confirm one is stored. `aiProvider` only names WHICH stored key to use. A search whose provider has no saved key fails at run time with a clear reason rather than at creation. THE AI FILTER RUNS ON YOUR KEY AND IS BILLED BY YOUR PROVIDER, NOT BY CORNERSIGHT, and nothing you set here caps it except the run itself: every post a run scans is sent to your model — up to 2,000 posts in each run, 10 to a call (about 200 calls), or one call per post when the model's batch answer cannot be read — and a post already judged under the same prompt is never sent again. `creditCap` bounds Cornersight credits only. EACH TERM IS ONE PROVIDER CALL per run, merged and deduplicated by post URN. The run's scan — at most 2,000 posts, an internal safety bound and not a setting — is shared across the terms and allocated round-robin, so one high-volume term cannot crowd out the others, and a term that exhausts early yields its share. Keyword searches have their own trial ceiling, separate from profiles and posts. Remove one with DELETE /api/v1/keyword/{id}. ⚠️ THE CUT-OVER IS SCHEDULED FOR 2026-11-01, AND THIS IS THE NOTICE OF IT. UNTIL 2026-11-01 an old-style create — one that omits the three scope fields and `confirmSpend` — still returns 201/200 and carries a `deprecations` entry naming exactly what to add. FROM 2026-11-01 the same request is refused: a missing scope field is `400` `scope_required` naming the field, and a scope that is stated but not confirmed is `409` `spend_confirmation_required` — the same refusal a request pinned to `contractVersion: "2026-11-01"` already gets today, which is how you test the new behaviour before the date. THE EXACT CHANGE IS FOUR KEYS: add "creditCap": 100, "captureMode": "depth", "datePosted": "PAST_WEEK" and "confirmSpend": true to the request body. Those three values are what the dashboard's "Search scope" step pre-fills, offered so you can copy them DELIBERATELY — they are NOT server-side defaults and nothing is chosen for you, so send the caps you actually want, and send `confirmSpend` only once the person has heard the daily figure. THE VERSION STRING AND THE CUT-OVER DATE ARE THE SAME DAY, which they did not have to be: the version is what you PIN, the date is what happens to you if you do not. IT RUNS EVERY DAY, AND CHARGES EVERY DAY. This is not a one-off search: once created, the sweep repeats roughly every 24 hours until you untrack it, and `creditCap` is PER RUN, not a total for the life of the search. The first sweep starts within seconds of creation, not a day later, and the daily cadence runs from there. CREATED, OR THE ONE YOU ALREADY HAVE: a keyword search's identity is its joined terms, so creating one whose keywords match a search this team already has — whether that search is ACTIVE or one you DELETED — returns THAT search instead of making a second one. The response is 200 with `resumed: true` rather than 201, and `seenPosts` says how many posts it has already swept and will therefore SKIP; a genuine create returns 201 with `resumed: false` and `seenPosts: 0`. THE SETTINGS YOU SEND ARE APPLIED TO THAT SEARCH AND THE ONES YOU OMIT ARE LEFT AS THEY ARE, so a bare `{"keywords": [...]}` returns it unchanged rather than resetting its name, its AI filter and its caps to the defaults; send an explicit `null` to clear a field. For the same reason `name` and `aiProvider` in the response are the search's CURRENT values, not an echo of what you sent. RESUMING A DELETED SEARCH ALSO TRACKS IT AGAIN and queues a sweep within seconds, which charges — if you did not mean to bring it back, untrack it again with DELETE /api/v1/keyword/{id}. A `creditCap` of 2000 means up to 2000 enriching credits EVERY DAY, not 2000 once. Set the caps to what one day may cost, and remove the search with DELETE /api/v1/keyword/{id} when you no longer want it running. Each run sweeps only posts it has not captured before — a post captured once is never re-swept, so successive runs return new people rather than re-charging for the same ones. ONE LIMIT: `creditCap`. It is the only limit you set on a search that captures people, and a run ends when it has spent it, when the provider has no new posts, or on the walk's safety bound (20 provider pages per term, and at most 2,000 posts scanned in one run) — never because a post count was reached. A credit is charged per NEW PERSON for this source, not per post or repeat engagement, so one post with 300 distinct new people can cost up to 300 credits. A `posts_only` search captures no people and is bounded by `postsPerSync` new posts or `creditCap` credits, whichever is lower. The retired per-run post limit, `postBudget`, is accepted and IGNORED if you still send it (since 30 September 2026), and the reply lists it in `ignoredFields`. WHAT ONE DAY CAN COST, AS A FORMULA — THE ONE PLACE IT IS WRITTEN DOWN. Every surface that quotes a spend estimate computes it the same way, from one shared function: `estimatedDailyMax = creditCap` for a search that captures people — with or without post authors — and `estimatedDailyMax = min(postsPerSync, creditCap)` for a `posts_only` search, which buys at most `postsPerSync` new posts a day at one credit each. POST /api/v1/keyword/estimate, this endpoint's 201/200 and its 409, and PATCH /api/v1/keyword/{id} all quote that one figure. The fields that look like they belong in it are deliberately not in it. `captureMode` is not, because `depth` and `breadth` only choose how the cap is SPREAD across a run's posts, and both stop at the same cap. `maxEngagementsPerPost` is not, and that is the one that surprises: the per-post ceiling times the posts a run reaches is often what a run actually spends, but it is not a limit we can promise — a post whose engagement counts the provider did not return is allocated the whole remaining cap, so a run with a low per-post ceiling can still spend the full `creditCap`. The estimate is therefore the most a day can cost, never the least: an estimate that came in under what you are charged would be worse than no estimate at all. When `creditCap` is absent, zero, negative or not a whole number there is NO estimate, rather than an estimate of zero. Against your team's remaining enriching-credit balance the same calculation gives `daysToExhaustAtCap = floor(balance / estimatedDailyMax)`: WHOLE days at that rate, so a balance of 250 against a cap of 100 is 2 and not 3, and any balance below the cap is 0 — the very first sweep is the one that gets cut short. With no usable cap there is no rate to divide by, so `daysToExhaustAtCap` is absent too: never zero, never infinite. BOTH FIGURES ARE RETURNED, not merely documented: this endpoint carries `estimatedDailyMax` and `daysToExhaustAtCap` beside the created search on the 201 AND on the 200 resume, computed from the caps that will actually bind the next run, and GET /api/v1/sources carries the same two inside a keyword source's `config`, so a search created on any surface can be read back. Each is OMITTED rather than zeroed whenever it has no honest value — test for the key, not for a number. WHY A RUN STOPPED, AND WHAT IT DID, is reported by GET /api/v1/sources on the source's `lastRun` object, which carries seventeen fields answering four questions. WHEN AND WHY IT ENDED: `at`, `stoppedBy` (the category), `reason` (the sentence behind it — for `ai_error` a sentence Cornersight owns, not the provider's raw response) and `aiErrorCode`, present only on an `ai_error`, which is the stable token to branch on: `model_not_found`, `invalid_key`, `rate_limited`, `out_of_credit` (the provider account behind the key has no credit: add credit with the provider, do not rotate the key) or `provider_error` — and `provider_error` is the provider's own outage, never a reason to tell someone to rotate a working key. WHAT THE PROVIDER RETURNED BUT CORNERSIGHT COULD NOT SAFELY CAPTURE: `providerPageLimitReached` records whether the 20-page safety bound ended a term before exhaustion was proven; `providerRowsDropped` counts provider result rows skipped because they lacked a capturable activity URN; the walk continues past those pages instead of ending early. It is rows across terms/pages, not necessarily unique posts, and is omitted for unmeasured runs. HOW MUCH IT LOOKED AT: `postsScanned` → `postsKept` is the AI filter's before/after, while `postsHarvested` is how far the run actually REACHED, which can be far below `postsKept` when a cap stopped it early. AND, ON A SEARCH BUILT FROM AN `expression`, `discardedByExpression` — and `postsFilteredOut`, the same number under its older name — is how many harvested posts its AND/NOT terms discarded, so the run summary reads searched `postsScanned`, discarded `discardedByExpression`, kept `postsKept`. Both are OMITTED for a search without an expression and PRESENT AND 0 when one discarded nothing, which is the distinction between an expression to rewrite (`postsHarvested: 0` beside `discardedByExpression: 47`) and a search that simply found nothing (the keys absent). A discarded post IS counted in `postsScanned` and NOT in `postsKept`, and GET /api/v1/sources/{id}/kept-posts?include=swept names each one with the clause it failed. WHAT BECAME OF THE PEOPLE, which is how a run that yielded almost nothing is diagnosed: `engagersSeen` is the denominator, `engagersDropped` counts engagers seen but not turned into leads — since 2026-09-08 that no longer means 'had no public handle', because those are now captured under their member URN; what is left is an engager with no identity at all, plus organisation pages, which were never leads, `engagersDuplicate` includes already-stored rows and free repeat engagements, `repeatEngagements` counts the latter subset, `leadsWritten` is inserted engagement rows, and `creditsSpent` counts newly charged people under the name that says what it cost — one credit is one newly chargeable person per source. The five capture counters are OMITTED, never zeroed, for runs predating them. Each field is documented on GET /api/v1/sources. `lastRun` is `null` for person, company and post sources (they have no sweep of their own) and is ALWAYS an object for a keyword search - with null members until the first run finishes, so "not a keyword source" stays distinguishable from "has not run yet". `stoppedBy` is one of `credits` (reached creditCap - the real spend bound; on a `posts_only` search only when creditCap was below postsPerSync), `post_limit` (a `posts_only` search bought its postsPerSync posts for the day - raise postsPerSync, not creditCap, for more), `exhausted` (the provider walk ended without a capture cap; `providerPageLimitReached: true` proves the walk's safety bound fired - 20 pages per term, or 2,000 posts in the run; false only says it did not, because an all-seen RELEVANCE page can also stop before later unseen posts), `error` (the sweep itself failed), `ai_error` (the team's own AI credential failed - the search is otherwise fine) or `capture_empty` (the sweep harvested real posts and every engager fetch answered and returned NOBODY - a CAPTURE failure, never a narrow search; a run that harvested nothing is a clean `exhausted` instead) - and `budget` only on a run from before 30 September 2026, when a per-run post limit existed that has since been retired. The difference that matters most: `credits` and `post_limit` mean a capture cap ended the run; `exhausted` means the provider walk ended without one. `providerPageLimitReached: true` proves the page bound fired; false only says it did not, because an all-seen RELEVANCE page can also stop before later unseen posts. WHEN AN EDIT TAKES EFFECT: FROM THE NEXT RUN, NEVER THE ONE ALREADY RUNNING. A sweep reads the search's configuration ONCE, when it starts, and holds it for the whole run - so settings changed while a sweep is in flight do not re-bind it, and a run that began on the old budgets finishes on them. A change made BEFORE the sweep starts does bind that run, including one made after its job was queued: the worker reads the configuration when it picks the job up, not when the job was created. That is why a run's counts can look like a cap overrun and not be one - read `lastRun.config` on GET /api/v1/sources, which is the snapshot of what THAT run was bound by, against `config`, which is what the search is set to now and what its next run will use. `lastRun.config` is omitted for runs that predate it and is never back-filled from the current settings. ON A TRIAL a team may hold at most 2 keyword searches, and each captures at most 250 leads.
Authenticated with the X-API-Key header.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | What this search is CALLED in your source list, e.g. "hiring an SDR". Optional: omit it and the search is titled by its keywords, which is what every search created before this field existed shows. Blank is rejected rather than treated as absent — omit the field instead. The name is a label only: the KEYWORDS remain the search's identity, so two searches may share a name but not a term-set. |
captureReplies | boolean | no | Whether this tracked search captures reply authors as leads. False skips replies before lead writes and credits.default: true |
enrichLeads | boolean | no | Whether this source's leads are enriched. Default true, what every source has always done. RAW MODE when false: this source's engagers are still captured and charged exactly as before — one credit per NEW person per source, repeats free, the same ledger and caps — but NEVER enriched: no job title, company or country, and no enrichment provider call. Those leads end enrichment status `raw` and are read with GET /api/v1/leads/raw; they never appear in GET /api/v1/leads, /engagers, exports, webhooks or integrations. A plain setting, not a spend change: the price is the same either way, so it never needs `confirmSpend`. It applies to leads captured or processed AFTER the change — leads already enriched stay enriched and raw leads stay raw. Stored on the search's tracked source. A resume (the same keywords again) with it changes the setting; omitting it leaves the stored value alone. It does not change the estimate: raw mode costs what enrichment costs.default: true |
keywords | string[] | no | One to ten search terms. EACH IS A SEPARATE PROVIDER CALL per run — the underlying search takes a single term and supports no OR syntax — and the results are merged and deduplicated by post URN, so a post two terms both find is captured once. The run's scan (at most 2,000 posts, an internal bound) is SHARED across the terms and allocated round-robin, so a high-volume term cannot crowd out the others; a term that runs out early yields its share to the rest. Terms must be unique, and a term containing a parenthesis is a 400 (the search provider refuses one). OPTIONAL SINCE THE BOOLEAN EXPRESSION FIELD: send `keywords` or `expression`, never both. A plain list is identical to the same terms joined with OR. A term wrapped in double quotes (straight or curly) is an EXACT PHRASE, as it is in `expression`: `"outbound playbook"` keeps only posts whose text has those words next to each other and in order, and discards the rest with the reason `missing phrase "outbound playbook"`. It is stored in straight quotes. An unquoted multi-word term stays a broad match. |
keyword | string | no | Convenience alias for a single-term search. Accepted as a one-element `keywords`. Prefer `keywords`. |
expression | string | no | A boolean expression INSTEAD OF a term list — `hiring AND "sales ops" NOT recruiter OR fundraising`. Exactly one of `keywords` or `expression` may be sent; both together is a 400, neither is a 400. GRAMMAR: NOT binds tighter than AND, which binds tighter than OR, and there are NO PARENTHESES — `a AND b OR c` means `(a AND b) OR c`. A PARENTHESIS IS A 400 that explains this precedence, with ONE exception: a search's own canonical `expression` (as the create reply and GET /api/v1/sources return it, e.g. `(hiring AND NOT recruiter) OR fundraising`) may be sent back UNCHANGED and compiles to the same search, so a search can be recreated from what it reports. Anything else bracketed — `(a OR b) AND c`, `NOT (a AND b)`, a stray `(` — is refused rather than guessed at, and NO TERM MAY CONTAIN A PARENTHESIS, even in quotes: the search provider refuses one. Operators are UPPER CASE ONLY: a lower-case `and` is an ordinary search term, which is what stops an existing term like `sales and marketing` changing meaning. Two terms with no operator between them means OR, which is exactly what a `keywords` list has always meant — so `{"expression": "a OR b"}` and `{"keywords": ["a","b"]}` create the identical search. A quoted phrase is one term; curly quotes count. AND A QUOTED PHRASE IS MATCHED AS A PHRASE in every expression shape, including a lone term and an OR-only branch. Its words must appear next to each other in order, ignoring case and allowing punctuation between them; a post with sales, finance, ops fails the quoted sales ops check. LinkedIn still receives one provider search for each required term and may return broader candidates, which the worker discards before the run's scan ceiling is applied. Unquoted terms and legacy keyword-list chips keep their broad provider matching. COST: only OR is served by the search itself — each required term is one provider call per run, the same as a `keywords` list of the same terms. AND and NOT CANNOT be expressed upstream (the provider takes one `keyword` string and documents no boolean syntax) and are applied afterwards by reading each post's own text, so a narrow expression spends the same provider searches and keeps fewer posts. The run applies them BEFORE its scan ceiling is allocated, so that ceiling counts posts that survived, and credits are only charged at capture — a discarded post costs no credits and IS marked as seen, so the next run does not re-harvest it. WHAT WAS DISCARDED IS REPORTED, not merely counted: `lastRun.discardedByExpression` (and its older name `lastRun.postsFilteredOut`) is how many, those posts are counted in `lastRun.postsScanned` and not in `lastRun.postsKept`, and GET /api/v1/sources/{id}/kept-posts?include=swept returns a row per discarded post carrying `outcome: "discarded"` and a `reason` naming the failing part of the expression, e.g. `missing phrase "sales ops"` or `contains recruiter`. The expression COMPILES to `keywords`: its required terms, de-duplicated, in first-appearance order, and that is what the reply and GET /api/v1/sources report. A malformed expression is a 400 at SAVE TIME, naming the token at fault; it is never stored. Max 10 required terms, 60 tokens, 1000 characters. CREATE-ONLY: PATCH /api/v1/keyword/{id} refuses it, because the seen-set is keyed to the SEARCH and a new expression would inherit the posts the old one rejected. |
sort | "RELEVANCE" | "DATE_POSTED" | no | ⚠️ Stored and reported back, but every keyword search currently RUNS BY RELEVANCE whatever this holds (since 5 October 2026: newest-first returned nothing for high-volume terms).default: "DATE_POSTED" |
datePosted | "PAST_24_HOURS" | "PAST_WEEK" | "PAST_MONTH" | no | How far back each sweep looks. PAST_MONTH is the provider's widest window.default: "PAST_WEEK" |
contentType | "VIDEO" | "IMAGE" | "JOB" | "LIVE_VIDEO" | "DOCUMENT" | "COLLABORATIVE_ARTICLE" | no | Omit for no content-type filter. |
authorIndustry | string[] | no | Keeps only posts whose author is in one of these industries. Applied by LinkedIn's own search, so the run's scan is spent on matching posts rather than on posts discarded afterwards. A NUMERIC LinkedIn industry id. Send it bare (`96`) or wrapped (`urn:li:industry:96`); both are accepted and stored as the wrapped form. An industry NAME is not an id. FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN — `"jasonlemkin"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run. |
authorCompany | string[] | no | Keeps only posts whose author currently works at one of these companies. A NUMERIC LinkedIn organisation id. Send it bare (`1441`) or wrapped (`urn:li:organization:1441`); both are accepted and stored as the wrapped form. A company NAME or slug is not an id. FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN — `"jasonlemkin"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run. |
authorKeyword | string[] | no | Plain words matched against the AUTHOR (headline, job title), not the post text. The only filter in this group that takes words rather than URNs. |
fromPerson | string[] | no | Keeps only posts written by these people. A LinkedIn MEMBER ID — `"AC"` followed by base64url characters, about 39 in all. Send it bare (`ACoAAA5eqCEBzmkNfjyOp9_MseBpRQ-P17SuIos`) or wrapped (`urn:li:person:ACoAAA5eqCEBzmkNfjyOp9_MseBpRQ-P17SuIos`); both are accepted and stored as the wrapped form, so `GET /api/v1/sources` reports one spelling whichever you sent. This is the SAME id profile enrichment returns as `entityUrn`. No member id to hand? `GET /api/v1/profile/{username}/urn` resolves any public handle for free — no enrichment, no tracking, `creditsCharged: 0` (CLI `profile-urn`, MCP `get_profile_urn`). FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN — `"jasonlemkin"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run. |
fromCompany | string[] | no | Keeps only posts published by these company pages. A NUMERIC LinkedIn organisation id. Send it bare (`1441`) or wrapped (`urn:li:organization:1441`); both are accepted and stored as the wrapped form. A company NAME or slug is not an id. FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN — `"jasonlemkin"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run. |
mentionsPerson | string[] | no | Keeps only posts that @mention one of these people. A LinkedIn MEMBER ID — `"AC"` followed by base64url characters, about 39 in all. Send it bare (`ACoAAA5eqCEBzmkNfjyOp9_MseBpRQ-P17SuIos`) or wrapped (`urn:li:person:ACoAAA5eqCEBzmkNfjyOp9_MseBpRQ-P17SuIos`); both are accepted and stored as the wrapped form, so `GET /api/v1/sources` reports one spelling whichever you sent. This is the SAME id profile enrichment returns as `entityUrn`. No member id to hand? `GET /api/v1/profile/{username}/urn` resolves any public handle for free — no enrichment, no tracking, `creditsCharged: 0` (CLI `profile-urn`, MCP `get_profile_urn`). FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN — `"jasonlemkin"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run. |
mentionsCompany | string[] | no | Keeps only posts that @mention one of these company pages. A NUMERIC LinkedIn organisation id. Send it bare (`1441`) or wrapped (`urn:li:organization:1441`); both are accepted and stored as the wrapped form. A company NAME or slug is not an id. FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN — `"jasonlemkin"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run. |
aiProvider | "openai" | "grok" | "gemini" | "claude" | no | WHICH stored credential filters the posts. The KEY ITSELF IS NOT ACCEPTED HERE and cannot be supplied through this API — save it once in the dashboard's AI filtering panel on Keyword Engagement, where it is held in an encrypted vault, one per provider per team. That panel also lists which providers your team already has a key for. Omit for no AI filter: every new post found is captured. |
aiModel | string | no | Model id for the chosen provider. OPTIONAL EVEN WHEN aiProvider IS SET — this field is not required and never has been; a create carrying aiProvider and aiPrompt without it is a 201. Omit it and the provider's DEFAULT MODEL is used: openai `gpt-6-luna`, grok `grok-4.3`, gemini `gemini-3.5-flash-lite`, claude `claude-haiku-4-5-20251001`. Those are a cheap, fast current model from each vendor for what the filter actually does (one keep/reject decision against your criterion), and they MOVE as vendors retire models — name a model here only when you want one pinned against that. A blank string is treated as unset, the same way the filter itself treats it. A model sent with NO aiProvider is a 400: there is no filter for it to configure, and storing it would leave a setting that never runs. |
aiPrompt | string | no | Your criterion, e.g. "posts where someone is hiring engineers". Required when aiProvider is set. EVERY POST A RUN SCANS IS SENT TO YOUR OWN AI PROVIDER, on your key and billed by that provider rather than in Cornersight credits: up to 2,000 posts in each run (the run's internal scan ceiling), 10 to a call — about 200 calls at most — or one call per post when the model's batch answer cannot be read. A post already judged under the same prompt is not sent again. `creditCap` does not bound this bill; it bounds Cornersight credits. |
postBudget | integer | no | RETIRED on 30 September 2026 and IGNORED. It was the per-run post limit; a keyword search now has exactly one limit you set, `creditCap`, and a run collects until it has spent it. Accepted with any value, on create, estimate and resume and under either `contractVersion`, so an older client is never refused for sending it — it changes nothing, is no longer a scope field the contract requires, and the reply lists it in `ignoredFields`. A `posts_only` search's own limit is `postsPerSync`, which is unaffected. |
creditCap | integer | no | Maximum enriching credits one run may spend, and the real limit on what a sweep costs — one credit per new person for this search; repeat engagements are free. PER RUN: the search repeats about every 24 hours, so a creditCap of N is up to N credits every day until the search is untracked. Default 100, and deliberately unbounded above — no PRODUCT ceiling is imposed, because this bounds a quantity you are paying for rather than one we define. The declared `maximum` of 2147483647 is a STORAGE bound, not a recommendation: it is the largest value the column holds, and a cap anywhere near it would be a spending authorisation renewed every day. WHAT BOUNDS THE SERIES is not this field but your team's monthly enriching-credit balance: a sweep is skipped entirely when the balance is exhausted, so a recurring search stops on its own rather than running forever. IT ALSO BOUNDS PROVIDER PAGES, not just rows written — the remaining allowance is what decides how many pages of reactions and comments are fetched per post, so a high cap costs provider calls as well as storage. The provider exposes no quota of its own, so this is the only spend control on a sweep.default: 100 |
mode | "engagers" | "posts_only" | no | engagers captures leads; posts_only saves matching post text without people or leads, charging one credit per new kept post.default: "engagers" |
postsPerSync | integer | no | Required with posts_only. Maximum new posts to charge in each daily run (1-60); repeats and rejected posts are free. |
captureEngagers | boolean | no | Capture the people who ENGAGED with each kept post — likes and comments. Default true, which is what every keyword search has always done. false fetches no reactions or comments at all (no provider calls for them) and requires `capturePostAuthors: true`: a search must capture someone (400 `no_capture_target` otherwise). Engagers mode only — sent with `mode: "posts_only"` it is a 400. Omitted on a resume or PATCH leaves the stored value alone. Turning it back ON for a search that captured post authors only needs `confirmSpend: true` (409 `spend_confirmation_required` otherwise): the daily figure is `creditCap` either way, but authors only can charge at most one new person per kept post, while engagers can spend the whole cap on one busy post.default: true |
capturePostAuthors | boolean | no | Capture the person who WROTE each kept post, as a lead with `engagementType: "Author"`. Default false. Read from the keyword search result itself, so it costs no extra provider call. Charged exactly like an engager: ONE credit per NEW person for this search; the same person again — on a later post, or also as a liker or commenter of the same post — is a free repeat (the two rows are kept, the person is charged once). A post whose author is a COMPANY PAGE captures no author and costs nothing; the run reports how many as `lastRun.companyAuthorsSkipped`. The credit cap, the team's daily keyword ceiling and a trial source's lead cap bind authors exactly as they bind engagers. With `captureEngagers: false` a run adds at most one new person per kept post, and `estimatedDailyMax` is still `creditCap` — the one limit you set. Engagers mode only.default: false |
captureMode | "depth" | "breadth" | no | How creditCap is spent across a run's posts. `depth` (the default, and the behaviour of every search created before this field) hands each post the whole remaining cap, so the run goes deep on the posts it reaches first — one high-engagement post can consume the entire cap and leave later posts uncaptured that run. `breadth` shares the cap across the run's posts by their engagement counts and redistributes the unspent remainder to posts that can absorb it, so the run fans across more posts with fewer engagers from each. Which engagers a capped post contributes is the provider's own order (reactions before comments), not most-recent or most-relevant.default: "depth" |
maxEngagementsPerPost | integer | no | Breadth mode only: an explicit per-post ceiling — capture at most this many engagements from any one post. Omit for no ceiling (the default: breadth fair-shares the whole creditCap across posts). `creditCap` remains the HARD spend limit and this only shapes distribution beneath it: if maxEngagementsPerPost times the posts a run reaches exceeds creditCap the cap still binds and later posts go uncaptured; if it is below creditCap the run spends less than the cap, on purpose — a deliberately narrower, more even sweep. Ignored in depth mode. The declared `maximum` is the storage bound (int4), not a product ceiling — breadth has no useful reason to approach it. |
runOnce | boolean | no | Harvest ONCE, then stop scheduling. Default false, which is the unbounded daily cadence every keyword search has always had. A run that FAILED does not satisfy it — see `maxRuns` for what counts as a run — so this means one harvest, not one attempt. Equivalent to `maxRuns: 1` and checked before it; setting both is legal and the tighter binds. A search stopped this way keeps its leads and stays in your source list; `schedule.stoppedAt` on GET /api/v1/sources says when, and `schedule.stoppedReason` says which control did it.default: false |
endAt | string <date-time> | no | A UTC instant after which this search stops scheduling. Must be in the FUTURE — a date already past is a 400, because it would create a search that is parked before it ever runs. Checked before each run as well as after it, so a search whose end passed while it was idle never sweeps again, including from a manual sync. Omit for no end date (the default); send `null` to clear one. Stored normalised to UTC. |
maxRuns | integer | no | Stop after this many COUNTED runs. A run COUNTS when it reached the provider and ended ordinarily (`exhausted`, `credits`, `post_limit`, or a `team_cap`/`lead_cap` that bound it mid-sweep). It does NOT count when the run FAILED (`error`, `ai_error`), when an untrack abandoned it, or when it was skipped before the provider was asked (a spent team ceiling or a full lead cap — both report `postsScanned: 0`). A sweep that ran and captured nobody DOES count. Raising this above the runs already completed RESTARTS a search that stopped at it — PATCH /api/v1/keyword/{id} clears the stop and queues the search again in the same call. Omit for no run budget (the default); send `null` to clear one. |
confirmChanges | boolean | no | ACKNOWLEDGE A REWRITE, as `confirmSpend` acknowledges a charge. Only ever relevant on a RESUME: these keywords already name a live search and this body carries a DIFFERENT value for a targeting filter, `sort`, `datePosted`, `contentType` or the AI filter. A resume applies every setting the call carries and leaves the rest alone, and it used to do so in silence — create with `fromPerson` A, then with `fromPerson` B, and the reply was 200 `resumed: true` with no `previous`, no `changed` and nothing to notice. Without this field such a call is now a `409` `settings_conflict` and NOTHING is changed. `confirmSpend` does NOT acknowledge a rewrite, and `confirmChanges` does not authorise a cap rise — two changes, two fields — so a body that does both carries both. It was accepted for both once, and that made the refusal unreachable from the CLI (`track-keyword` requires `--confirm-spend`, so every create it sent arrived pre-acknowledged and rewrote live searches in silence) and from any caller that sent it early. The pair still costs ONE round trip: a `spend_confirmation_required` refusal on a body that would also rewrite something carries `changed` and names it. Ignored when nothing would change. Accepted and inert on POST /api/v1/keyword/estimate, which changes nothing to acknowledge. |
confirmSpend | boolean | no | THE SPEND CONFIRMATION, and the one field that is about consent rather than configuration. `true` means the person who asked for this search has been shown what one day of it can cost and has agreed to it. THE THRESHOLD IS EVERY CREATE — there is no credit figure below which it is skipped — because a keyword search is a RECURRING DAILY CHARGE whose only upper bound is your team's monthly balance, and the thing being authorised is the commitment, not an amount. It is required on a create, on a RESUME (keywords matching an existing search re-cap THAT search rather than making a second one) and on any update that RAISES what one day can cost (`creditCap`, or `postsPerSync` on a `posts_only` search). Without it the request is refused `409` with code `spend_confirmation_required`, whose body carries `creditCap`, `captureMode`, `datePosted`, `estimatedDailyMax`, `remainingBalance` and `daysToExhaustAtCap` — the numbers the dashboard puts above its Confirm button. Show them to the person, then re-send the identical request with this field added. DURING THE WARNING PHASE (see `contractVersion`) an omitted `confirmSpend` still returns 201/200, with a `deprecations` entry naming it and the cut-over date after which it will not. ⚠️ THE CUT-OVER IS SCHEDULED FOR 2026-11-01, AND THIS IS THE NOTICE OF IT. UNTIL 2026-11-01 an old-style create — one that omits the three scope fields and `confirmSpend` — still returns 201/200 and carries a `deprecations` entry naming exactly what to add. FROM 2026-11-01 the same request is refused: a missing scope field is `400` `scope_required` naming the field, and a scope that is stated but not confirmed is `409` `spend_confirmation_required` — the same refusal a request pinned to `contractVersion: "2026-11-01"` already gets today, which is how you test the new behaviour before the date. THE EXACT CHANGE IS FOUR KEYS: add "creditCap": 100, "captureMode": "depth", "datePosted": "PAST_WEEK" and "confirmSpend": true to the request body. Those three values are what the dashboard's "Search scope" step pre-fills, offered so you can copy them DELIBERATELY — they are NOT server-side defaults and nothing is chosen for you, so send the caps you actually want, and send `confirmSpend` only once the person has heard the daily figure. THE VERSION STRING AND THE CUT-OVER DATE ARE THE SAME DAY, which they did not have to be: the version is what you PIN, the date is what happens to you if you do not. |
contractVersion | "2026-09-09" | "2026-11-01" | no | WHICH REVISION OF THIS ENDPOINT'S CONTRACT TO BE HELD TO, so an integration can move on its own schedule instead of on ours. `2026-11-01` is the spend contract: `creditCap`, `captureMode` and `datePosted` all required, and `confirmSpend: true` required — send it and those rules apply to this request immediately, whichever phase the server is in. REVISED ON 30 SEPTEMBER 2026, BEFORE ITS CUT-OVER: it named a fourth required field, `postBudget`, which left the contract when the per-run post limit was retired. The version string did not change because the revision only removes a requirement — nothing that was accepted before is refused now — and a request that still sends `postBudget`, pinned or not, is accepted with the value ignored and listed in the reply's `ignoredFields`. `2026-09-09` DECLARES the older contract (no scope fields, no confirmation), which is what an omitted `contractVersion` also gets, and is accepted only until the cut-over; after it, a request pinned to the old version is refused like any other and the message says the pin is why. ANY OTHER VALUE IS A `400` with code `invalid_contract_version`, in both phases — a misspelled opt-in that was silently ignored would mean believing you had moved when you had not. THE ROLL-OUT IN ONE SENTENCE: today an old-style create still succeeds and carries `deprecations` telling you exactly what to add and by when; from the cut-over the same request is a `400` (`scope_required`) or a `409` (`spend_confirmation_required`). The cut-over instant is published on every `deprecations` entry as `cutoverAt`, and is `null` there while it is unscheduled. ⚠️ THE CUT-OVER IS SCHEDULED FOR 2026-11-01, AND THIS IS THE NOTICE OF IT. UNTIL 2026-11-01 an old-style create — one that omits the three scope fields and `confirmSpend` — still returns 201/200 and carries a `deprecations` entry naming exactly what to add. FROM 2026-11-01 the same request is refused: a missing scope field is `400` `scope_required` naming the field, and a scope that is stated but not confirmed is `409` `spend_confirmation_required` — the same refusal a request pinned to `contractVersion: "2026-11-01"` already gets today, which is how you test the new behaviour before the date. THE EXACT CHANGE IS FOUR KEYS: add "creditCap": 100, "captureMode": "depth", "datePosted": "PAST_WEEK" and "confirmSpend": true to the request body. Those three values are what the dashboard's "Search scope" step pre-fills, offered so you can copy them DELIBERATELY — they are NOT server-side defaults and nothing is chosen for you, so send the caps you actually want, and send `confirmSpend` only once the person has heard the daily figure. THE VERSION STRING AND THE CUT-OVER DATE ARE THE SAME DAY, which they did not have to be: the version is what you PIN, the date is what happens to you if you do not. The date above is the published schedule; `cutoverAt` carries it as an instant once this deployment has been configured with it, and is `null` until then — so read the date from this documentation and treat `null` as "not configured here yet" rather than as "not happening". |
Request examples
{
"keywords": [
"AI agents"
]
}{
"datePosted": "PAST_WEEK",
"sort": "DATE_POSTED",
"aiProvider": "openai",
"aiModel": "gpt-4o-mini",
"aiPrompt": "Keep posts where the author is hiring. Reject recruiters advertising services.",
"creditCap": 100,
"keywords": [
"hiring engineers",
"we are hiring",
"join our team"
]
}Responses
200 — Nothing was created: this team already had a search with these keywords, and it is returned with `resumed: true`. Settings you sent were applied to it; ones you omitted were left as they were. If it had been untracked it is now tracked again, with a sweep queued. `estimatedDailyMax` and `daysToExhaustAtCap` therefore describe the search AS IT NOW STANDS, not your request: a bare `{"keywords"}` returns the price of the search that was already there. A resumed search sweeps within seconds too, so the daily figure is as worth relaying here as on a create.
| Field | Type | Required | Description |
|---|---|---|---|
filtering | object | no | WHETHER THIS SEARCH WILL FILTER THE POSTS IT FINDS, recorded beside what it can cost because the two are the same decision seen from either end. `applied` is true when the search will run with at least one targeting filter (`authorKeyword`, `authorIndustry`, `authorCompany`, `fromPerson`, `fromCompany`, `mentionsPerson`, `mentionsCompany`) or with a complete AI filter (`aiProvider` AND a non-blank `aiPrompt`). ⚠️ IT DESCRIBES THE SEARCH AS IT NOW STANDS, NOT YOUR REQUEST: on a RESUME a filter you did not mention is left in place, so a bare `{"keywords"}` against an AI-filtered search reports `true` — the same rule `name` and `aiProvider` above follow. A MISSING FILTER IS NEVER A REASON TO REFUSE: `applied: false` is a record of your choice, not a fault, and a create that omits both kinds is a 201 exactly as it always was. There is no `suggestion` key here — the offer is made once, on the `409`, at the moment you are being asked about the cost. |
└applied | boolean | no | True when at least one targeting filter, or a complete AI filter (provider AND a non-blank prompt), will be in force for the next run. Always present, so a caller can branch on it without probing. A provider with no prompt is not a filter — it is a `400` on the way in. |
└aiKeyStoredFor | string[] | no | Which of `openai`, `grok`, `gemini` and `claude` this team has an AI key stored for — sorted, de-duplicated, and possibly empty. Answered from the stored credential's provider column; the key itself is never decrypted, never returned, and cannot be sent to this endpoint at all (it is saved once per provider in the dashboard). It is here so you can tell whether an AI filter is possible RIGHT NOW without a second call: with an empty list, only the targeting filters are available. |
id | string <uuid> | no | The tracked source's id — the same id GET /api/v1/sources returns, and what DELETE /api/v1/keyword/{id} takes. On a duplicate this is the EXISTING search's id, with its original createdAt. |
name | string | null | no | What the search is called, or null if it is titled by its terms. The search's CURRENT name — on a duplicate that omitted `name`, the stored one, not the null your request implied. |
keywords | string[] | no | The terms as stored, trimmed — and, when this search was created from an `expression`, the terms that expression COMPILED to: its required terms, de-duplicated, in first-appearance order. Always the terms actually searched for, whichever way the search was made. |
matchLogic | string | no | Present only when `keywords` has more than one term and no `expression` was sent — the plain-list OR shape. Says plainly that this search matches ANY of these terms, not all of them together, and names the `expression` syntax to use instead if that is not what was wanted. Absent for a single term and for any `expression`: typing AND, OR or NOT is itself the acknowledgement, so this never second-guesses a caller who already named the logic. |
expression | string | no | The boolean expression this search was created from, in CANONICAL form — operators upper case, the implicit OR written out, and a multi-literal group bracketed when there is more than one group, e.g. `(hiring AND NOT recruiter) OR fundraising`. It is NOT an echo of what was sent: the canonical form is where the precedence is visible. It is also the one bracketed form the create accepts: sent back unchanged as `expression`, it compiles to the same search. READ IT BACK before you rely on the search — it is how you confirm the operators were parsed the way you meant. `null` for every search created from a plain `keywords` list. As with `name` and `aiProvider`, on a RESUME this is the search's CURRENT value rather than an echo of the request. |
aiProvider | string | null | no | Which stored credential filters this search's posts, or null for no AI filter. As with `name`, the search's CURRENT value rather than an echo of the request. |
captureEngagers | boolean | no | Whether the search captures people who liked or commented (migration 176). Null on a `posts_only` search, which captures no people. |
capturePostAuthors | boolean | no | Whether the search captures each kept post's author as an `Author` lead (migration 176). Null on a `posts_only` search, which captures no people. |
resumed | boolean | no | false on a genuine create (201). true when this team already had a search with these keywords and it was returned instead (200) — active or previously deleted. Always present, so a caller can branch on it without probing. |
seenPosts | integer | no | How many posts the returned search has already swept and will therefore SKIP: a resumed search inherits its seen-set and does not start clean. 0 on a genuine create. |
syncId | string | null | no | Id of the first sweep queued, or null if the team's plan does not allow capture. |
estimatedDailyMax | integer | no | THE MOST ONE DAY OF THIS SEARCH CAN COST, in enriching credits — the same number the dashboard shows as "This search can cost up to N credits a day" above its Confirm button, produced by the one shared function every surface quoting a spend estimate reads. The formula — `creditCap`, or `min(postsPerSync, creditCap)` for a `posts_only` search — and the fields deliberately not in it are written out in this operation's description. RELAY IT TO WHOEVER ASKED FOR THE SEARCH, before or as you create it: the first sweep starts within SECONDS of this response and the charge repeats every day until the search is untracked, so there is no window in which to check afterwards and no confirmation step on this endpoint. OMITTED — not `0`, not `null` — when `creditCap` is missing or is not a positive whole number, because "up to 0 credits a day" is a promise the product cannot keep. Test for the KEY's presence (`'estimatedDailyMax' in body`), never for a value. |
daysToExhaustAtCap | integer | no | WHOLE days your team's remaining enriching-credit balance funds at that daily maximum. `0` IS A REAL ANSWER AND THE ALARMING ONE: the balance cannot fund one whole day at this cap, so the sweep this request has already queued is the one that gets cut short. OMITTED — never zero, never infinite — when there is no rate to divide by (`estimatedDailyMax` is absent too) or the balance could not be read at all, as on a team with no enriching plan. Both of those mean the question has no answer, which is a different thing from the answer being none, so test for the KEY's presence. |
ignoredFields | "postBudget"[] | no | The fields this request sent that NOTHING READS ANY MORE, so a setting you still send is never dropped in silence. Today that can only be `postBudget` — the per-run post limit, retired on 30 September 2026: accepted with any value, applied to nothing, and named here. ABSENT when the request sent none. |
deprecations | object[] | no | WHAT THIS CREATE WOULD BE REFUSED FOR ONCE THE SPEND CONTRACT IS ENFORCED, and the exact object to add so that it is not. PRESENT ONLY DURING THE WARNING PHASE, and only when this request did not state the whole scope — a caller who already sends the four scope fields and `confirmSpend: true` gets a body with no `deprecations` key at all, and so does every caller once the phase ends, because then the same request is a `400` or a `409` rather than a warning. Each entry carries `code` (`spend_confirmation_required`), `contractVersion`, `cutoverAt` (an ISO instant, or `null` while the cut-over is unscheduled; THE CUT-OVER IS SCHEDULED FOR 2026-11-01), `message`, and `add`. ⚠️ `add` IS THE VALUE THIS REQUEST ACTUALLY USED, not a recommendation: on a create those are the dashboard's pre-fills, and on a RESUME they are the existing search's own settings, because an omitted field leaves that search's caps alone. Merging `add` into your request body therefore changes nothing about what runs — it only makes the decision explicit, which is the whole of what the new contract asks for. |
└code | string | no | `spend_confirmation_required` — the same code the 409 carries, so one branch handles both. |
└contractVersion | string | no | The contract this warning is about: `2026-11-01`. |
└cutoverAt | string | null | no | The instant the new contract becomes the default, ISO 8601. `null` while it is unscheduled — never an invented date. ⚠️ THE CUT-OVER IS SCHEDULED FOR 2026-11-01, AND THIS IS THE NOTICE OF IT. UNTIL 2026-11-01 an old-style create — one that omits the three scope fields and `confirmSpend` — still returns 201/200 and carries a `deprecations` entry naming exactly what to add. FROM 2026-11-01 the same request is refused: a missing scope field is `400` `scope_required` naming the field, and a scope that is stated but not confirmed is `409` `spend_confirmation_required` — the same refusal a request pinned to `contractVersion: "2026-11-01"` already gets today, which is how you test the new behaviour before the date. THE EXACT CHANGE IS FOUR KEYS: add "creditCap": 100, "captureMode": "depth", "datePosted": "PAST_WEEK" and "confirmSpend": true to the request body. Those three values are what the dashboard's "Search scope" step pre-fills, offered so you can copy them DELIBERATELY — they are NOT server-side defaults and nothing is chosen for you, so send the caps you actually want, and send `confirmSpend` only once the person has heard the daily figure. THE VERSION STRING AND THE CUT-OVER DATE ARE THE SAME DAY, which they did not have to be: the version is what you PIN, the date is what happens to you if you do not. The date above is the published schedule; `cutoverAt` carries it as an instant once this deployment has been configured with it, and is `null` until then — so read the date from this documentation and treat `null` as "not configured here yet" rather than as "not happening". |
└add | object | no | The exact JSON to merge into the request body. Values are the ones this request already resolved to, so merging them is a no-op in behaviour. |
└message | string | no |
201 — The keyword search is now tracked. `resumed` is `false` and `seenPosts` is `0`: this search is new. `estimatedDailyMax` says what one day of it can cost and `daysToExhaustAtCap` how long your balance funds that — quote the daily figure to whoever asked for the search, because the first sweep runs within seconds of this response. Either is omitted when it has no honest value.
| Field | Type | Required | Description |
|---|---|---|---|
filtering | object | no | WHETHER THIS SEARCH WILL FILTER THE POSTS IT FINDS, recorded beside what it can cost because the two are the same decision seen from either end. `applied` is true when the search will run with at least one targeting filter (`authorKeyword`, `authorIndustry`, `authorCompany`, `fromPerson`, `fromCompany`, `mentionsPerson`, `mentionsCompany`) or with a complete AI filter (`aiProvider` AND a non-blank `aiPrompt`). ⚠️ IT DESCRIBES THE SEARCH AS IT NOW STANDS, NOT YOUR REQUEST: on a RESUME a filter you did not mention is left in place, so a bare `{"keywords"}` against an AI-filtered search reports `true` — the same rule `name` and `aiProvider` above follow. A MISSING FILTER IS NEVER A REASON TO REFUSE: `applied: false` is a record of your choice, not a fault, and a create that omits both kinds is a 201 exactly as it always was. There is no `suggestion` key here — the offer is made once, on the `409`, at the moment you are being asked about the cost. |
└applied | boolean | no | True when at least one targeting filter, or a complete AI filter (provider AND a non-blank prompt), will be in force for the next run. Always present, so a caller can branch on it without probing. A provider with no prompt is not a filter — it is a `400` on the way in. |
└aiKeyStoredFor | string[] | no | Which of `openai`, `grok`, `gemini` and `claude` this team has an AI key stored for — sorted, de-duplicated, and possibly empty. Answered from the stored credential's provider column; the key itself is never decrypted, never returned, and cannot be sent to this endpoint at all (it is saved once per provider in the dashboard). It is here so you can tell whether an AI filter is possible RIGHT NOW without a second call: with an empty list, only the targeting filters are available. |
id | string <uuid> | no | The tracked source's id — the same id GET /api/v1/sources returns, and what DELETE /api/v1/keyword/{id} takes. On a duplicate this is the EXISTING search's id, with its original createdAt. |
name | string | null | no | What the search is called, or null if it is titled by its terms. The search's CURRENT name — on a duplicate that omitted `name`, the stored one, not the null your request implied. |
keywords | string[] | no | The terms as stored, trimmed — and, when this search was created from an `expression`, the terms that expression COMPILED to: its required terms, de-duplicated, in first-appearance order. Always the terms actually searched for, whichever way the search was made. |
matchLogic | string | no | Present only when `keywords` has more than one term and no `expression` was sent — the plain-list OR shape. Says plainly that this search matches ANY of these terms, not all of them together, and names the `expression` syntax to use instead if that is not what was wanted. Absent for a single term and for any `expression`: typing AND, OR or NOT is itself the acknowledgement, so this never second-guesses a caller who already named the logic. |
expression | string | no | The boolean expression this search was created from, in CANONICAL form — operators upper case, the implicit OR written out, and a multi-literal group bracketed when there is more than one group, e.g. `(hiring AND NOT recruiter) OR fundraising`. It is NOT an echo of what was sent: the canonical form is where the precedence is visible. It is also the one bracketed form the create accepts: sent back unchanged as `expression`, it compiles to the same search. READ IT BACK before you rely on the search — it is how you confirm the operators were parsed the way you meant. `null` for every search created from a plain `keywords` list. As with `name` and `aiProvider`, on a RESUME this is the search's CURRENT value rather than an echo of the request. |
aiProvider | string | null | no | Which stored credential filters this search's posts, or null for no AI filter. As with `name`, the search's CURRENT value rather than an echo of the request. |
captureEngagers | boolean | no | Whether the search captures people who liked or commented (migration 176). Null on a `posts_only` search, which captures no people. |
capturePostAuthors | boolean | no | Whether the search captures each kept post's author as an `Author` lead (migration 176). Null on a `posts_only` search, which captures no people. |
resumed | boolean | no | false on a genuine create (201). true when this team already had a search with these keywords and it was returned instead (200) — active or previously deleted. Always present, so a caller can branch on it without probing. |
seenPosts | integer | no | How many posts the returned search has already swept and will therefore SKIP: a resumed search inherits its seen-set and does not start clean. 0 on a genuine create. |
syncId | string | null | no | Id of the first sweep queued, or null if the team's plan does not allow capture. |
estimatedDailyMax | integer | no | THE MOST ONE DAY OF THIS SEARCH CAN COST, in enriching credits — the same number the dashboard shows as "This search can cost up to N credits a day" above its Confirm button, produced by the one shared function every surface quoting a spend estimate reads. The formula — `creditCap`, or `min(postsPerSync, creditCap)` for a `posts_only` search — and the fields deliberately not in it are written out in this operation's description. RELAY IT TO WHOEVER ASKED FOR THE SEARCH, before or as you create it: the first sweep starts within SECONDS of this response and the charge repeats every day until the search is untracked, so there is no window in which to check afterwards and no confirmation step on this endpoint. OMITTED — not `0`, not `null` — when `creditCap` is missing or is not a positive whole number, because "up to 0 credits a day" is a promise the product cannot keep. Test for the KEY's presence (`'estimatedDailyMax' in body`), never for a value. |
daysToExhaustAtCap | integer | no | WHOLE days your team's remaining enriching-credit balance funds at that daily maximum. `0` IS A REAL ANSWER AND THE ALARMING ONE: the balance cannot fund one whole day at this cap, so the sweep this request has already queued is the one that gets cut short. OMITTED — never zero, never infinite — when there is no rate to divide by (`estimatedDailyMax` is absent too) or the balance could not be read at all, as on a team with no enriching plan. Both of those mean the question has no answer, which is a different thing from the answer being none, so test for the KEY's presence. |
ignoredFields | "postBudget"[] | no | The fields this request sent that NOTHING READS ANY MORE, so a setting you still send is never dropped in silence. Today that can only be `postBudget` — the per-run post limit, retired on 30 September 2026: accepted with any value, applied to nothing, and named here. ABSENT when the request sent none. |
deprecations | object[] | no | WHAT THIS CREATE WOULD BE REFUSED FOR ONCE THE SPEND CONTRACT IS ENFORCED, and the exact object to add so that it is not. PRESENT ONLY DURING THE WARNING PHASE, and only when this request did not state the whole scope — a caller who already sends the four scope fields and `confirmSpend: true` gets a body with no `deprecations` key at all, and so does every caller once the phase ends, because then the same request is a `400` or a `409` rather than a warning. Each entry carries `code` (`spend_confirmation_required`), `contractVersion`, `cutoverAt` (an ISO instant, or `null` while the cut-over is unscheduled; THE CUT-OVER IS SCHEDULED FOR 2026-11-01), `message`, and `add`. ⚠️ `add` IS THE VALUE THIS REQUEST ACTUALLY USED, not a recommendation: on a create those are the dashboard's pre-fills, and on a RESUME they are the existing search's own settings, because an omitted field leaves that search's caps alone. Merging `add` into your request body therefore changes nothing about what runs — it only makes the decision explicit, which is the whole of what the new contract asks for. |
└code | string | no | `spend_confirmation_required` — the same code the 409 carries, so one branch handles both. |
└contractVersion | string | no | The contract this warning is about: `2026-11-01`. |
└cutoverAt | string | null | no | The instant the new contract becomes the default, ISO 8601. `null` while it is unscheduled — never an invented date. ⚠️ THE CUT-OVER IS SCHEDULED FOR 2026-11-01, AND THIS IS THE NOTICE OF IT. UNTIL 2026-11-01 an old-style create — one that omits the three scope fields and `confirmSpend` — still returns 201/200 and carries a `deprecations` entry naming exactly what to add. FROM 2026-11-01 the same request is refused: a missing scope field is `400` `scope_required` naming the field, and a scope that is stated but not confirmed is `409` `spend_confirmation_required` — the same refusal a request pinned to `contractVersion: "2026-11-01"` already gets today, which is how you test the new behaviour before the date. THE EXACT CHANGE IS FOUR KEYS: add "creditCap": 100, "captureMode": "depth", "datePosted": "PAST_WEEK" and "confirmSpend": true to the request body. Those three values are what the dashboard's "Search scope" step pre-fills, offered so you can copy them DELIBERATELY — they are NOT server-side defaults and nothing is chosen for you, so send the caps you actually want, and send `confirmSpend` only once the person has heard the daily figure. THE VERSION STRING AND THE CUT-OVER DATE ARE THE SAME DAY, which they did not have to be: the version is what you PIN, the date is what happens to you if you do not. The date above is the published schedule; `cutoverAt` carries it as an instant once this deployment has been configured with it, and is `null` until then — so read the date from this documentation and treat `null` as "not configured here yet" rather than as "not happening". |
└add | object | no | The exact JSON to merge into the request body. Values are the ones this request already resolved to, so merging them is a no-op in behaviour. |
└message | string | no |
| Status | Meaning | Example error |
|---|---|---|
| 400 | `keywords` is missing, empty, longer than ten terms, contains a duplicate or an over-long term, or aiProvider/aiPrompt were not supplied together. Also returned with code `invalid_enum_value` when `sort`, `datePosted`, `contentType`, `captureMode` or `aiProvider` is not one of its declared values — the endpoint validates these before creating anything, so a rejected request leaves no source behind. Also returned with code `invalid_number` when `creditCap` or `maxEngagementsPerPost` is not a whole number within its declared range — a value below the minimum, above the maximum, fractional, or not a number at all. These were previously ACCEPTED and silently replaced by the field's default: `creditCap: 0` returned 201 and stored 100, authorising 100 enriching credits every day. They are rejected before anything is created, so nothing is stored and no sweep is queued. Also returned with code `scope_required` once the SPEND CONTRACT is enforced (SCHEDULED FOR 2026-11-01), when `creditCap`, `captureMode` or `datePosted` is missing (an explicit `null` counts as missing): the body carries `field` naming which one, and the message states the exact JSON to add, offering the value the dashboard pre-fills so you can copy it deliberately — there is no server-side default. One field per refusal, in the dashboard's own order. Also returned with code `invalid_contract_version` — in BOTH phases — for a `contractVersion` outside the accepted list, and without a code for a `confirmSpend` that is not a boolean. Every one of these is refused before anything is created. Also returned with code `invalid_urn` when a value in `authorIndustry`, `authorCompany`, `fromPerson`, `fromCompany`, `mentionsPerson` or `mentionsCompany` is not an id of the right shape. The body carries `field`, `value`, `expected` and `example` beside the message, so the fix needs no sentence parsing. A HANDLE IS NOT A URN: `fromPerson: ["jasonlemkin"]` used to be accepted and then failed at the provider on EVERY daily run, reported as a retryable outage. Resolve a handle for free with `GET /api/v1/profile/{username}/urn`. `authorKeyword` is plain words and is NOT checked. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The delete is forbidden — either the team's subscription is inactive / its trial has expired, or the profile is a trial profile (trial profiles cannot be deleted; subscribe to a paid plan to manage profiles). | Team subscription not active |
| 409 | Code `spend_confirmation_required`. The scope was stated but the spend was not confirmed: `confirmSpend` was absent or `false`, and the contract is being enforced (the cut-over — SCHEDULED FOR 2026-11-01 — has passed, or this request pinned `contractVersion: "2026-11-01"`). NOTHING WAS CREATED OR CHANGED — no source, no sweep, no charge — and re-sending the identical request with `"confirmSpend": true` is what resolves it. The body carries the numbers the dashboard shows above its Confirm button, from the same shared function: `creditCap`, `captureMode` and `datePosted` (the settings the next run would be bound by — on a resume those are the live search's, where this request omitted them), `estimatedDailyMax`, `remainingBalance` and `daysToExhaustAtCap`, plus `contractVersion`. The last three are OMITTED rather than nulled when there is no honest number — test for the KEY, as everywhere else on this endpoint. SHOW THOSE NUMBERS TO THE PERSON before retrying, and do not shrink the caps to get past the refusal. The body ALSO carries `filtering`: `applied` (always `false` when a suggestion is quoted), `suggestion` (one sentence, written for the person, mirroring the dashboard's step 2 — "Every matched post is tracked while this is off.") and `aiKeyStoredFor` (which of `openai`, `grok`, `gemini`, `claude` this team has a key for, sorted and possibly empty). ⚠️ IT IS NOT PART OF THE REFUSAL. This `409` is about `confirmSpend` and nothing else; a create carrying no filter has never been refused, and re-sending it with `"confirmSpend": true` and still no filter is a `201`. Offer the filter alongside the cost figures ONCE and respect the answer. `suggestion` names a prompt only when `aiKeyStoredFor` is non-empty, because a key is stored in the dashboard and cannot be sent here. Filtering reduces daily spend by rejecting posts before their engagers are captured; an AI prompt still scores every fetched post on the team's own provider key, so it trades one bill against another rather than removing one. Also returned with code `identifier_in_use`. A source identifier is unique per team across all four kinds, and these joined keywords are already held by a source of a DIFFERENT kind (a profile, a company page or a tracked post) — the message names which. Use different terms; nothing was created. ALSO RETURNED WITH CODE `settings_conflict`, and that one is not about money at all. These keywords already name a live search, so this call would RESUME it and rewrite settings it is running on today — a targeting filter, `sort`, `datePosted`, `contentType` or the AI filter. NOTHING WAS CHANGED. The body carries `previous` (the search's FULL current settings), `settings` (what it would become) and `changed` (each differing field as `{ field, from, to }`). Read `changed` to the person who asked, then re-send the identical request with `"confirmChanges": true`. THAT IS THE ONLY FIELD THAT ACKNOWLEDGES A REWRITE: `"confirmSpend": true` authorises the recurring CHARGE and nothing else, so a body that RAISES A CAP *and* rewrites a setting carries BOTH flags, and each refusal names the one still missing. It was accepted for both once, which made this refusal unreachable from the CLI — `track-keyword` requires `--confirm-spend`, so every create it sent arrived pre-acknowledged — and from any caller that sent it early. ⚠️ AND THE `spend_confirmation_required` 409 ABOVE CARRIES `changed` TOO whenever the same body would also rewrite a setting: it is answered FIRST and it asks you to re-send with `confirmSpend`, so it names the rewrite as well and one reading is enough to send both flags together. To leave the live search alone, omit those fields: an omitted setting is never rewritten, so a bare `{"keywords"}` returns the search unchanged. A resume that changes nothing, or that only LOWERS a cap, is a plain 200. The caps are deliberately NOT in `changed` — a RAISE has its own `spend_confirmation_required` refusal and lowering one is never refused — and neither is `name`, which costs nothing and changes no behaviour. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 500 | Unexpected server error — the request failed on our side (e.g. a database error), not because of the request itself. On a WRITE endpoint some work may already have been applied before the failure, so prefer re-reading state over blindly retrying, or you can end up with duplicates. | Failed to list leads |
| 502 | The current keyword search settings could not be verified because a database read failed. Nothing is changed, queued, or charged by this request. Retry when the database is available. |
Update a keyword search's settings
/api/v1/keyword/{id}Change a live keyword search's settings — its caps, its scope, its AI filter and its provider-side targeting — without re-creating it. WHY IT EXISTS. Until now the only way to re-cap a live search from outside the dashboard was to call POST /api/v1/keyword/track again with the same keywords, which RESUMES the search and applies whatever settings the call carries. That works, but it makes "raise the cap on my live search" and "create a search" the same request. This is the request that says which. Partial update: only the fields present in the body change, an omitted one is left exactly as it is, and an explicit `null` clears it — to null on a nullable column, and to the create-time default on a NOT NULL one (`creditCap` 100, `captureMode` depth, `sort` DATE_POSTED, `datePosted` PAST_WEEK), which is the only coherent reading of "clear" for those. ⚠️ A CHANGE THAT RAISES WHAT ONE DAY CAN COST NEEDS `"confirmSpend": true` — that figure is `estimatedDailyMax`: `creditCap` on a search that captures people, `min(postsPerSync, creditCap)` on a `posts_only` search, so raising `creditCap` alone on a `posts_only` search whose `postsPerSync` is below it needs no confirmation — and is otherwise a `409` `spend_confirmation_required` whose message names the search and the new daily cap, carrying `previous` (the caps the search holds now), the resulting `creditCap`/`captureMode`/`datePosted`, `raised` (which setting pushed the figure up: `creditCap`, or `postsPerSync`), `estimatedDailyMax`, `remainingBalance` and `daysToExhaustAtCap` — the same numbers the dashboard puts above its Confirm button, and the same 409 shape POST /api/v1/keyword/track returns. NOTHING WAS CHANGED; re-send the identical request with `"confirmSpend": true` to authorise it, and do not shrink the cap to get past the refusal. LOWERING a cap, leaving it alone, or changing anything else needs no confirmation, because none of those increases what a day can cost. The rule is NOT tied to the spend contract's cut-over date: it applies today, on this endpoint and on a resume alike. WHEN IT TAKES EFFECT: FROM THE NEXT RUN. A sweep reads its settings once, when it starts, so a run already in flight finishes on the caps it began with — read `lastRun.config` on GET /api/v1/sources for what the last run was actually bound by, and `config` for what the next one will use. An edit made before the sweep starts, including after its job is queued, does bind that run. `keywords` CANNOT BE CHANGED HERE and sending it is a `400`, not a silent drop: a search's terms are its identity, and its seen-set is keyed to the SEARCH rather than to a term, so new terms would inherit the old ones' swept posts and sweep a smaller universe than they appear to. Create a search with the new terms and untrack this one. Identify the search by the SOURCE `id` GET /api/v1/sources returns — the same id DELETE /api/v1/keyword/{id} takes and POST /api/v1/keyword/track echoes back — never by its keyword text, which is what `username` shows. Mirrored as the CLI's `keyword-update` and MCP's `update_keyword`. UNKNOWN FIELDS ARE REFUSED: a property not listed here is a 400 carrying `code: "unknown_field"` and naming the offending field, rather than a 200 that silently dropped it.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | string <uuid> | yes | The keyword search's source id, from GET /api/v1/sources. Not its keyword text, and not the dashboard's keyword_searches.id. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | What the search is called in the source list. `null` clears it, and the list then shows the terms themselves. |
sort | "RELEVANCE" | "DATE_POSTED" | no | How the provider orders the posts each sweep considers. `null` resets it to DATE_POSTED. ⚠️ Stored and reported back, but every keyword search currently RUNS BY RELEVANCE whatever this holds (since 5 October 2026: newest-first returned nothing for high-volume terms). |
datePosted | "PAST_24_HOURS" | "PAST_WEEK" | "PAST_MONTH" | no | How far back each sweep looks. A SCOPE field. `null` resets it to PAST_WEEK. |
contentType | "VIDEO" | "IMAGE" | "JOB" | "LIVE_VIDEO" | "DOCUMENT" | "COLLABORATIVE_ARTICLE" | no | Restrict the sweep to one kind of post. `null` clears the restriction. |
authorIndustry | string[] | no | Keeps only posts whose author is in one of these industries. A NUMERIC LinkedIn industry id. Send it bare (`96`) or wrapped (`urn:li:industry:96`); both are accepted and stored as the wrapped form. An industry NAME is not an id. FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN — `"jasonlemkin"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run. Send `[]` to clear this filter — the column is `text[] NOT NULL DEFAULT '{}'`, so `[]` IS its unset state. |
authorCompany | string[] | no | Keeps only posts whose author currently works at one of these companies. A NUMERIC LinkedIn organisation id. Send it bare (`1441`) or wrapped (`urn:li:organization:1441`); both are accepted and stored as the wrapped form. A company NAME or slug is not an id. FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN — `"jasonlemkin"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run. Send `[]` to clear this filter — the column is `text[] NOT NULL DEFAULT '{}'`, so `[]` IS its unset state. |
authorKeyword | string[] | no | Plain words matched against the AUTHOR (headline, title), not the post text — the one filter here that is NOT a URN. Send `[]` to clear this filter — the column is `text[] NOT NULL DEFAULT '{}'`, so `[]` IS its unset state. |
fromPerson | string[] | no | Keeps only posts written by these people. A LinkedIn MEMBER ID — `"AC"` followed by base64url characters, about 39 in all. Send it bare (`ACoAAA5eqCEBzmkNfjyOp9_MseBpRQ-P17SuIos`) or wrapped (`urn:li:person:ACoAAA5eqCEBzmkNfjyOp9_MseBpRQ-P17SuIos`); both are accepted and stored as the wrapped form, so `GET /api/v1/sources` reports one spelling whichever you sent. This is the SAME id profile enrichment returns as `entityUrn`. No member id to hand? `GET /api/v1/profile/{username}/urn` resolves any public handle for free — no enrichment, no tracking, `creditsCharged: 0` (CLI `profile-urn`, MCP `get_profile_urn`). FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN — `"jasonlemkin"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run. Send `[]` to clear this filter — the column is `text[] NOT NULL DEFAULT '{}'`, so `[]` IS its unset state. |
fromCompany | string[] | no | Keeps only posts published by these company pages. A NUMERIC LinkedIn organisation id. Send it bare (`1441`) or wrapped (`urn:li:organization:1441`); both are accepted and stored as the wrapped form. A company NAME or slug is not an id. FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN — `"jasonlemkin"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run. Send `[]` to clear this filter — the column is `text[] NOT NULL DEFAULT '{}'`, so `[]` IS its unset state. |
mentionsPerson | string[] | no | Keeps only posts that @mention one of these people. A LinkedIn MEMBER ID — `"AC"` followed by base64url characters, about 39 in all. Send it bare (`ACoAAA5eqCEBzmkNfjyOp9_MseBpRQ-P17SuIos`) or wrapped (`urn:li:person:ACoAAA5eqCEBzmkNfjyOp9_MseBpRQ-P17SuIos`); both are accepted and stored as the wrapped form, so `GET /api/v1/sources` reports one spelling whichever you sent. This is the SAME id profile enrichment returns as `entityUrn`. No member id to hand? `GET /api/v1/profile/{username}/urn` resolves any public handle for free — no enrichment, no tracking, `creditsCharged: 0` (CLI `profile-urn`, MCP `get_profile_urn`). FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN — `"jasonlemkin"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run. Send `[]` to clear this filter — the column is `text[] NOT NULL DEFAULT '{}'`, so `[]` IS its unset state. |
mentionsCompany | string[] | no | Keeps only posts that @mention one of these company pages. A NUMERIC LinkedIn organisation id. Send it bare (`1441`) or wrapped (`urn:li:organization:1441`); both are accepted and stored as the wrapped form. A company NAME or slug is not an id. FORMAT IS ENFORCED: a value that is not one of these is a `400` with code `invalid_urn` naming the field, the offending value, the expected shape and one example. A HANDLE IS NOT A URN — `"jasonlemkin"` is a vanity slug the provider cannot resolve, and before this was enforced a search carrying one was accepted and then failed on EVERY daily run. Send `[]` to clear this filter — the column is `text[] NOT NULL DEFAULT '{}'`, so `[]` IS its unset state. |
aiProvider | "openai" | "grok" | "gemini" | "claude" | no | Which STORED key filters the posts. THE KEY ITSELF IS NOT ACCEPTED HERE and is returned by no endpoint. `null` turns the AI filter off. THE TRIO MOVES AS ONE: naming any of aiProvider/aiModel/aiPrompt rewrites all three, because a prompt with no provider (or a model naming a provider that is no longer set) is a state the create path refuses, and patching them independently is the only way to reach it. |
aiModel | string | no | Model id for the chosen provider. Optional even with aiProvider: omit it (or send `null`) and the provider default runs — openai gpt-6-luna, grok grok-4.3, gemini gemini-3.5-flash-lite, claude claude-haiku-4-5-20251001. Part of the AI trio above. |
aiPrompt | string | no | The criterion, in plain language. Required when aiProvider is set. Part of the AI trio above. Every post a run scans is sent to your own AI provider on your key — up to 2,000 posts in each run, 10 to a call — and billed by that provider; `creditCap` does not bound it. |
postBudget | integer | no | RETIRED on 30 September 2026 and IGNORED: the per-run post limit is no longer a setting, and a search's one spend limit is `creditCap`. Accepted with any value so an older client keeps working — it changes nothing, never needs `confirmSpend`, and is listed back in `ignoredFields`. |
creditCap | integer | no | Maximum enriching credits one run may spend, PER RUN — the search repeats about every 24 hours, so N is up to N credits EVERY DAY until it is untracked. RAISING it needs `confirmSpend: true`; lowering it does not. There is no product ceiling: 2147483647 is only what the column can hold. |
mode | "engagers" | "posts_only" | no | engagers (default) captures leads. posts_only stores matching post text, charges one credit per new kept post, and captures no people or leads. |
postsPerSync | integer | no | Required for posts_only. Maximum new posts charged in one daily run; repeats and rejected posts are free. A raise requires confirmSpend. |
captureEngagers | boolean | no | Capture the people who ENGAGED with each kept post — likes and comments. Default true, which is what every keyword search has always done. false fetches no reactions or comments at all (no provider calls for them) and requires `capturePostAuthors: true`: a search must capture someone (400 `no_capture_target` otherwise). Engagers mode only — sent with `mode: "posts_only"` it is a 400. Omitted on a resume or PATCH leaves the stored value alone. Turning it back ON for a search that captured post authors only needs `confirmSpend: true` (409 `spend_confirmation_required` otherwise): the daily figure is `creditCap` either way, but authors only can charge at most one new person per kept post, while engagers can spend the whole cap on one busy post.default: true |
capturePostAuthors | boolean | no | Capture the person who WROTE each kept post, as a lead with `engagementType: "Author"`. Default false. Read from the keyword search result itself, so it costs no extra provider call. Charged exactly like an engager: ONE credit per NEW person for this search; the same person again — on a later post, or also as a liker or commenter of the same post — is a free repeat (the two rows are kept, the person is charged once). A post whose author is a COMPANY PAGE captures no author and costs nothing; the run reports how many as `lastRun.companyAuthorsSkipped`. The credit cap, the team's daily keyword ceiling and a trial source's lead cap bind authors exactly as they bind engagers. With `captureEngagers: false` a run adds at most one new person per kept post, and `estimatedDailyMax` is still `creditCap` — the one limit you set. Engagers mode only.default: false |
captureMode | "depth" | "breadth" | no | How creditCap is spent across a run's posts. depth lets one post spend the whole remaining cap; breadth shares the cap across the run's posts by their engagement counts and redistributes the unspent. Changing it does not change the cap, so it never needs confirmation. `null` resets it to depth. |
maxEngagementsPerPost | integer | no | Breadth mode only: cap each post at this many engagements. `null` clears it back to no explicit ceiling (spread the whole creditCap across posts). creditCap stays the hard limit, so this only shapes the spend beneath it and never raises it. |
captureReplies | boolean | no | Whether reply authors are captured as leads. Defaults to true for existing and new sources; false skips reply authors before lead writes and credit charges. |
enrichLeads | boolean | no | Whether this source's leads are enriched. Default true, what every source has always done. RAW MODE when false: this source's engagers are still captured and charged exactly as before — one credit per NEW person per source, repeats free, the same ledger and caps — but NEVER enriched: no job title, company or country, and no enrichment provider call. Those leads end enrichment status `raw` and are read with GET /api/v1/leads/raw; they never appear in GET /api/v1/leads, /engagers, exports, webhooks or integrations. A plain setting, not a spend change: the price is the same either way, so it never needs `confirmSpend`. It applies to leads captured or processed AFTER the change — leads already enriched stay enriched and raw leads stay raw. Stored on the search's tracked source (like captureReplies), not among its keyword settings, so `settings` in the response does not carry it; it is echoed at the top level when sent. Omit it to leave the stored setting alone. |
runOnce | boolean | no | Harvest ONCE, then stop scheduling. `false` clears the bound and returns the search to the unbounded daily cadence. A run that FAILED does not satisfy it — see `maxRuns` for what counts — so this means one harvest, not one attempt. SETTING IT TO false ON A SEARCH THAT ALREADY STOPPED FOR IT RESTARTS THE SEARCH: the stop is cleared and the search is queued in this same call.default: false |
endAt | string <date-time> | no | A UTC instant after which this search stops scheduling; `null` clears it. Must be in the FUTURE — a date already past is a 400, and nothing is written. Moving a passed end date forward RESTARTS a search that stopped for it: the stop is cleared and the search is queued in this same call. |
maxRuns | integer | no | Stop after this many COUNTED runs; `null` clears the budget. A run COUNTS when it reached the provider and ended ordinarily (`exhausted`, `credits`, `post_limit`, or a `team_cap`/`lead_cap` that bound it mid-sweep); a FAILED run (`error`, `ai_error`), an untracked run and a run skipped before the provider was asked do NOT count, and a sweep that captured nobody DOES. ⭐ RAISING IT ABOVE `schedule.runsCompleted` IS HOW A STOPPED SEARCH IS RESTARTED, and this endpoint does both halves: it clears `schedule.stoppedAt`/`stoppedReason` AND makes the source due again, so the next sweep runs within a minute rather than never. An edit that leaves the search stopped — a budget still at or below the runs it has had — deliberately does NOT clear the reason it is stopped. |
confirmSpend | boolean | no | THE AUTHORISATION FOR A CAP RISE, and the one field you must not supply on the person's behalf without asking. Required ONLY when this update would RAISE what one day of the search can cost — its `creditCap`, or `min(postsPerSync, creditCap)` on a `posts_only` search — above what it is now; a lowering, an equal figure, and an update that moves neither are all accepted without it. Send true only after the person has heard the daily figure the 409 quotes. |
contractVersion | "2026-09-09" | "2026-11-01" | no | WHICH REVISION OF THE SPEND CONTRACT TO BE HELD TO, accepted here so that a body this endpoint takes is a body POST /api/v1/keyword/track and POST /api/v1/keyword/estimate also take. THE ACCEPTED VALUES ARE THE SAME TWO, and ANY OTHER VALUE — a misspelled date, a word, a number, a non-string — IS A `400` with code `invalid_contract_version`, in both phases, BEFORE ANY COLUMN IS WRITTEN. A misspelled pin that was silently dropped would mean believing you had moved when you had not, and on an update it would be dropped from a call that still succeeded. ⚠️ IT CHANGES NOTHING ABOUT THIS ENDPOINT'S ANSWER, and that is deliberate rather than an oversight. The confirmation gate on a cap RISE is phase-independent and version-independent: `confirmSpend: true` is required to raise what one day can cost whichever version you pin and whichever phase the server is in, because "confirmation is required on any update that RAISES the cap" has been published since the contract shipped and there is no caller written against a laxer rule. Pin it if you pin it everywhere else; the only thing it buys here is that a typo is told to you. |
Responses
200 — The settings as they now stand, RE-SELECTED FROM THE DATABASE rather than echoed from the request — so every field this call left alone is reported at its real value, not as the null the body implied. `{ id, username, profileType: "keyword", settings: { name, keywords, sort, datePosted, contentType, creditCap, captureMode, maxEngagementsPerPost, aiProvider, aiModel, aiPrompt, authorIndustry, authorCompany, authorKeyword, fromPerson, fromCompany, mentionsPerson, mentionsCompany, expression } }`, plus `estimatedDailyMax` and `daysToExhaustAtCap` recomputed from what was stored — both OMITTED, never null and never 0, when there is no honest number — and `ignoredFields` when the body still sent the retired `postBudget`.
| Field | Type | Required | Description |
|---|---|---|---|
id | string <uuid> | yes | The SOURCE id this call was addressed by. |
username | string | yes | The search's keyword text, exactly as GET /api/v1/sources reports it. |
profileType | "keyword" | yes | |
settings | object | yes | Every settings field, as stored after this update. |
enrichLeads | boolean | no | Present only when the request named it: the raw-mode setting now stored on the search's tracked source, re-read after the write (false = raw). |
estimatedDailyMax | integer | no | The most ONE DAY of this search can cost in enriching credits. Omitted when the cap is unusable. |
daysToExhaustAtCap | integer | no | Whole days the team's remaining balance funds at that rate. Omitted when there is no rate or no readable balance. |
ignoredFields | "postBudget"[] | no | The fields this request sent that NOTHING READS ANY MORE, so a setting you still send is never dropped in silence. Today that can only be `postBudget` — the per-run post limit, retired on 30 September 2026: accepted with any value, applied to nothing, and named here. ABSENT when the request sent none. |
| Status | Meaning | Example error |
|---|---|---|
| 400 | The body named no setting at all; or it carried `keywords`/`keyword`, which cannot be changed here; or a setting was invalid — the SAME checks POST /api/v1/keyword/track applies, from the same validator, with the same codes: `invalid_enum_value` for `sort`, `datePosted`, `contentType`, `captureMode` or `aiProvider`, `invalid_number` for `creditCap` or `maxEngagementsPerPost` outside its declared range, and the aiProvider/aiPrompt both-or-neither rule. Also returned with code `invalid_contract_version` for a `contractVersion` outside the accepted list — the same 400 the create and the estimate return for the same value, in both phases; a pin this endpoint does not recognise is refused rather than dropped, so a typo cannot read as an update that honoured it. Also returned when the path segment is not a source id. Nothing was changed. Also returned with code `invalid_urn` when a value in `authorIndustry`, `authorCompany`, `fromPerson`, `fromCompany`, `mentionsPerson` or `mentionsCompany` is not an id of the right shape. The body carries `field`, `value`, `expected` and `example` beside the message, so the fix needs no sentence parsing. A HANDLE IS NOT A URN: `fromPerson: ["jasonlemkin"]` used to be accepted and then failed at the provider on EVERY daily run, reported as a retryable outage. Resolve a handle for free with `GET /api/v1/profile/{username}/urn`. `authorKeyword` is plain words and is NOT checked. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 404 | This team has no keyword search with that source id — the same answer another team's id gets, and the answer a source row with no settings row gets. | |
| 409 | Code `spend_confirmation_required`. This update would RAISE what one day of the search can cost — `estimatedDailyMax`, which is `creditCap`, or `min(postsPerSync, creditCap)` on a `posts_only` search — and `confirmSpend` was not `true`. NOTHING WAS CHANGED — no column written, no sweep re-bound. The message describes THIS EDIT: it names the search and the daily cap it would be raised to, because the caller named the search by id and sent no keywords (the create's resume sentence about keywords belonging to a search is not used here). The body is the same shape POST /api/v1/keyword/track's 409 carries (`code`, `contractVersion`, the resulting `creditCap`/`captureMode`/`datePosted`, `estimatedDailyMax`, `remainingBalance`, `daysToExhaustAtCap`, and `filtering`) plus `previous`, the three the search holds NOW, and `raised`, naming which setting pushed the figure up. `filtering` is the same object the create's `409` carries — `applied`, `aiKeyStoredFor` and, only when `applied` is `false`, `suggestion` — describing the search AS THIS UPDATE WOULD LEAVE IT: a filter this request does not name is inherited from the search, so patching only a cap on an AI-filtered search reports `applied: true`. ⚠ IT IS NOT PART OF THE REFUSAL. This `409` is about `confirmSpend` and nothing else; an unfiltered search has never been refused for being one, and re-sending with `"confirmSpend": true` and still no filter succeeds. SHOW THE PERSON THE BEFORE, THE AFTER AND THE DAILY FIGURE, then re-send the identical request with `"confirmSpend": true`. The last three numeric keys are OMITTED rather than nulled when there is no honest number — test for the KEY. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
Untrack a keyword search
/api/v1/keyword/{id}Stop a keyword search and stop capturing from it. The delete half of the pair with POST /api/v1/keyword/track. Synchronous. SOFT DELETE, like untracking a profile or a post: the source is deactivated, never removed, because leads reference it and a row delete would cascade the captured leads away. Sweeping stops and the search leaves the tracked-source list; the leads it already captured are kept and stay listed by GET /api/v1/leads. REACH THEM AFTERWARDS with `GET /api/v1/leads?sourceKind=keyword`, which spans stopped searches as well as running ones, or put the search back in the list with `GET /api/v1/sources?includeInactive=true` — its id still works as a `profileId`. Without one of those two the search is no longer enumerable, and its leads are reachable only by an id you recorded before untracking. Untracking one that is already untracked returns `404` with `code: "keyword_delete_already_stopped"` and `error: "That search is already stopped."` — DISTINGUISHABLE from the `404` for a search that never existed, which carries no code. The status is the same for both on purpose (untracking twice behaves as it did when the row was hard-deleted), but for a keyword search the two are not the same fact: an already-stopped search is still listed by `GET /api/v1/sources?includeInactive=true`, its id still resolves on `GET /api/v1/leads` and `GET /api/v1/engagers`, and its leads are still served. BRANCH ON `code`, never on the prose. It is the same value the dashboard has emitted for this case for months. Identify it by the source `id` GET /api/v1/sources returns — the same value POST /api/v1/keyword/track echoes back as `id`. Not by the keyword text. `403` for a search created during a free trial: trial sources cannot be deleted on any surface, the dashboard included, until the team subscribes. Stops the daily sweep and with it the daily charge. Leads already captured are kept. The `id` is the SOURCE id — the `id` field of the object POST /api/v1/keyword/track returned, and the one GET /api/v1/sources lists — not a keyword string. WHAT HAPPENS TO WORK THAT IS ALREADY RUNNING — the half this used to leave unsaid, and the half that costs money. A SWEEP ALREADY RUNNING IS STOPPED: the run asks whether its source is still tracked at every checkpoint it passes (before each provider call, before each post is harvested, before each chunk of lead rows) and abandons the run at the first checkpoint after the delete. THAT IS “AT THE NEXT CHECKPOINT”, NOT “INSTANTLY”: a provider call already in flight finishes first, and the check is coalesced behind a short window (one second by default), so expect the stop within moments rather than at the instant the delete returns. If the status read itself fails the run CARRIES ON to the next checkpoint — the control channel fails open on purpose, because abandoning a paying customer's sweep over one timed-out SELECT is the worse error. LEADS ALREADY WRITTEN ARE KEPT, and the run is finalised as `completed` with `stoppedBy: "untracked"` on GET /api/v1/sources/{id}/sync (and the username-keyed /sync routes). ⚠️ THAT VALUE IS NOT IN `lastRun.stoppedBy` on GET /api/v1/sources and never will be: that enum describes how a keyword SWEEP ended and is a closed set. QUEUED ENRICHMENT IS WRITTEN OFF AND NOT CHARGED: enrichment lands minutes to hours after capture, so the backlog is the part of an untracked source that would otherwise go on billing — every lead of this source still `pending` or `processing` is marked `skipped` with the reason `source_untracked` and costs no enriching credits. Re-tracking this source returns exactly those leads to `pending` at the start of its next sync, and returns no other skipped lead with it.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | string <uuid> | yes | The tracked source's id, from GET /api/v1/sources. |
Responses
200 — The keyword search is untracked. The source is deactivated; its captured leads are kept.
| Field | Type | Required | Description |
|---|---|---|---|
ok | boolean | yes | |
username | string | yes | The (normalized) public identifier of the deleted tracked profile. |
profileType | "person" | "company" | yes | |
untracked | boolean | yes | Always `true` — the tracked profile and its cascaded records were deleted. |
| Status | Meaning | Example error |
|---|---|---|
| 400 | The path segment is not a source id. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The delete is forbidden — either the team's subscription is inactive / its trial has expired, or the profile is a trial profile (trial profiles cannot be deleted; subscribe to a paid plan to manage profiles). | Team subscription not active |
| 404 | No keyword search with that id for this team, OR one that is already stopped — the two are told apart by `code`: "keyword_delete_already_stopped" for an already-stopped search (which is still enumerable with ?includeInactive=true and whose leads are still served), and no code at all when there is no such search. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 500 | Unexpected server error — the request failed on our side (e.g. a database error), not because of the request itself. On a WRITE endpoint some work may already have been applied before the failure, so prefer re-reading state over blindly retrying, or you can end up with duplicates. | Failed to list leads |
Get sync status for a tracked profile
/api/v1/profile/{username}/syncProgress of the background capture sync for a tracked personal LinkedIn profile. Tracking (saveTrackedProfile=true) queues a staged sync — collecting_posts → collecting_engagements → enriching → completed — and the jobId returned by the enrichment endpoints covers ONLY the profile enrichment, not this sync. Poll this to tell queued / in progress / complete-with-0-leads / failed apart, and stop when `isFinal` is true. THE LIFECYCLE INCLUDES ENRICHMENT, AND SO DOES `isFinal`: capture writes the engagement records, then new people for this source are enriched with firmographics for one credit each; their repeat engagement rows are free, and that second half normally runs on well after the capture run itself has ended. `state` therefore stays `enriching` and `isFinal` stays false while leads are still being enriched and billed — read `enrichment` for the pending / completed / failed / skipped breakdown, and `capture` when all you need to know is that collection finished. Duration is not fixed: the sync is queued, and how long it takes depends on current API load and how many posts and engagements the source has. Leads appear progressively while it runs.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | Public identifier of the tracked personal profile (not a full URL), e.g. demo-profile. |
Responses
200 — Current sync state for the tracked source.
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | |
profileType | "person" | "company" | yes | |
syncId | string <uuid> | no | The background sync run. Null when no sync has ever been queued. |
state | "queued" | "collecting_posts" | "collecting_engagements" | "enriching" | "completed" | "failed" | "paused" | yes | Stage of the source's background lifecycle, capture AND enrichment. Mirrors the dashboard's stages: collecting_posts → collecting_engagements → enriching. `enriching` also covers enrichment that continues after the capture run has finished — the usual case, since enrichment runs off the capture job — so a source stays `enriching` until nothing is left in `enrichment.pending`. `paused` means enriching credits ran out; it resumes automatically when credits return. |
description | string | no | One-line, human-readable explanation of `state`. |
isFinal | boolean | yes | True when the WHOLE lifecycle is over — capture finished and no lead is still queued for enrichment — so nothing further will change without a new run: stop polling. It is NOT set while enrichment is still running and still spending credits. For capture alone, read `capture.isFinal`. |
queuedAt | string <date-time> | no | |
startedAt | string <date-time> | no | |
updatedAt | string <date-time> | no | |
completedAt | string <date-time> | no | When the run finished, capture and enrichment both. Null whenever `isFinal` is false, so it can never read as "done" over leads that are still being enriched. For the capture run's own stamp, read `capture.completedAt`. |
capture | object | yes | The capture half on its own: posts walked and engagement records written, saying nothing about enriching them. `state`, `isFinal` and `completedAt` are exactly what the top-level fields of the same names reported before they were corrected to describe the whole lifecycle, so a caller that only ever needed "collection finished" reads `capture.isFinal` here. `stoppedBy`, `creditsSpent`, `leadRowsCaptured` and `coverage` beside them say WHY the run ended where it did, what it was charged, how many lead rows it wrote and how much of what the provider declared it holds — the answer to "why did this source come back small", which progress counts alone could never give. |
└state | "queued" | "collecting_posts" | "collecting_engagements" | "enriching" | "completed" | "failed" | "paused" | no | The capture run's own stage. |
└isFinal | boolean | no | True once the capture run reached a terminal state, whatever enrichment is still doing. |
└completedAt | string <date-time> | no | When the capture run finished. Enrichment normally continues past this moment. |
└stoppedBy | "exhausted" | "credits" | "capture_empty" | "provider_limit" | null | no | WHY THE CAPTURE RUN ENDED, or `null`. `credits` means the source's own `creditCapPerSync` was reached and the run stopped THERE — there was more to collect, and raising the limit would get it. `exhausted` means the run collected every engagement it found, so a bigger limit would change nothing. `capture_empty` means it collected POSTS and captured NOBODY from them — not a quiet week and not an ending anybody chose: between 12 and 14 September 2026 the upstream engager endpoints answered 200 with no rows for three days and every sync reported `completed` with `error: null` while leads/day went 3,150 to 0. `errorCode` carries the same marker and `error` the sentence "Harvested N posts, captured nobody." A sync that collected NO posts never reports it. `provider_limit` means the DATA PROVIDER stopped serving a post's reactions with at least one full page (50) of them still declared, and no cap was the reason: the capture holds materially fewer people than the post declares and NO LIMIT WILL GET THE REST, because the provider will not serve them. Measured 29 September 2026: a post declaring 3,490 reactions stopped at about 1,100 people (22 pages of ~50, then only empty pages, every one still declaring 3,490). `coverage` beside it gives the declared and captured counts. The rule counts the rows the provider SERVED, reactions it sends with no identity included, against the declared total, so a post served in full still ends `exhausted` (418 declared, 404 identifiable and 14 anonymous is complete); it is the rule POST /api/v1/post/reactions uses for `exhausted: false`, so the two never disagree about one post. A run the credit cap stopped reports `credits`, never this. Before this field the two were the SAME RESPONSE: "collected 100 because that was everything" and "collected 100 because 100 was the cap" both arrived as progress counts with nothing to tell them apart. THE WORDS ARE `lastRun.stoppedBy`'s ON GET /api/v1/sources, deliberately — the keyword side closed this same gap first, and a second vocabulary for one question is how a caller ends up writing two branches for one fact. ⚠ `null` IS FOUR DIFFERENT THINGS AND ALL FOUR ARE HONEST: the first sync has not finished yet; the run FAILED (neither word is true of a run that broke — read `state` and `errorCode`); the source was UNTRACKED mid-run, an ending this vocabulary has no word for and which the TOP-LEVEL `stoppedBy` reports as `untracked`; or this is a KEYWORD search, whose sweep records its ending on `lastRun.stoppedBy` at GET /api/v1/sources and never here. The TOP-LEVEL `stoppedBy` on this same response names the SAME ending, spelling the cap `credit_cap` and an untracked run `untracked`. |
└creditsSpent | integer | no | WHAT THIS RUN WAS CHARGED, in credits, or `null` — the same ledger GET /api/v1/credits/usage totals, attributed to this run: the credits charged for the leads it created (one per NEW person per source — a repeat engager's row, and a lead whose enrichment failed, cost nothing), the posts a posts-only watch bought (one credit each), or, for a keyword search, what its sweep spent (the number `lastRun.creditsSpent` reports). ⚠ CHANGED IN THIS RELEASE: it used to be the lead-ROW count, which read as an overspend (46 rows beside a `creditCap` of 30 that charged 30) and as free for a paid posts-only run (0). That count is now `leadRowsCaptured`. Enrichment charges a run's leads AFTER the capture ends, so while `isFinal` is false this is the charge SO FAR; it is settled when `isFinal` is true. PRESENT ON A FAILED RUN TOO, unlike `stoppedBy`: the leads a run wrote before it broke are still enriched and charged. `null` until the capture reaches a terminal state, and `null` — never a guessed `0` — when the charge could not be read. A `0` is a measurement. |
└leadRowsCaptured | integer | no | The lead ROWS this run wrote — what `creditsSpent` reported before this release. NOT what it cost: billing charges once per new person per source, so a repeat engager writes a row for free and a posts-only watch writes none. `creditCapPerSync` caps these rows. `null` until the capture reaches a terminal state and for a run that predates the counter; present on a failed run too. A `0` is a measurement. |
└coverage | object | no | DECLARED BESIDE CAPTURED: the reactions + comments the provider declared on the posts this run swept, and the leads this source now holds on those same posts — so a post the provider cut off reads `{"declared": 3690, "captured": 1147}` instead of being left to be inferred. `captured` is cumulative (a re-sync that adds 5 people to a post it already holds 1,045 of reports 1,050) and counts PEOPLE, deduplicated, while `declared` counts engagements, so even a post captured in full reads a little under (reactions with no identity, company pages, one person who both reacted and commented). The gap that matters is the one `stoppedBy: "provider_limit"` names. `null` until the capture is over, for a run recorded before this field, for a keyword search or a posts-only watch (which record no engagement coverage), and whenever either number is missing — half a pair is never served. |
└declared | integer | yes | |
└captured | integer | yes | |
enrichment | object | yes | The source's leads by enrichment outcome. The four buckets partition `total`, so the gap between captured and enriched leads is always attributable. THIS IS WHERE THE CREDITS GO: one credit per newly enriched person per source; repeat engagement rows are free, so spend continues after capture completes. A run's billing is settled and its downstream processing done when `pending` reaches 0 — the same moment `isFinal` becomes true, `leadsReady` flips, and the run's `lead.detected` webhooks and provider integrations fire. |
└total | integer | no | All leads held for this source, across runs. The same number as `progress.leadsTotal`. |
└pending | integer | no | Queued or in flight: work still owed, and still to be billed. Above 0 means this source is not finished, whatever the capture run says. |
└completed | integer | no | Enriched: carries firmographics. This is a row count, not billed credits; repeats of a person already charged for this source are free. For a source in raw mode (`enrichLeads: false`) it counts the leads captured raw and charged instead — done, never enriched, carrying no firmographics, and read with GET /api/v1/leads/raw. |
└failed | integer | no | Enrichment attempts exhausted. Terminal and never charged — these are reported rather than left holding the lifecycle open. |
└skipped | integer | no | Terminal with no data to add: the provider returned nothing for the person, or the team has no chargeable enriching plan. Never charged. |
progress | object | yes | |
└postsCollected | integer | no | Posts found on the source in this run. |
└engagementsCaptured | integer | no | Engagements (likes + comments) captured in this run. |
└leadsTotal | integer | no | All leads currently held for this source, across runs. |
└leadsEnriched | integer | no | Of those, how many carry enriched firmographics. Unchanged, and equal to `enrichment.completed`; `enrichment` accounts for the rest. For a source in raw mode (`enrichLeads: false`) it counts the leads captured raw and charged, which carry none. |
stoppedBy | "credit_cap" | "exhausted" | "capture_empty" | "provider_limit" | "untracked" | null | no | HOW THIS RUN ENDED — the SAME ending `capture.stoppedBy` names, so the two fields never disagree. `credit_cap` means the source's own per-sync credit limit (tracked_profiles.credit_cap, reported as `creditCapPerSync` on GET /api/v1/sources) was reached and the run stopped there — an ORDINARY ending, not a failure: `state` stays `completed`, `errorCode` is null, nothing broke. It is the ending `capture.stoppedBy` spells `credits`: both spellings were published first and callers branch on each, so neither is renamed. `exhausted`, `capture_empty` and `provider_limit` mean exactly what they mean on `capture.stoppedBy`. `untracked` means the source was untracked while the run was in flight and the sweep was abandoned — an ending the capture vocabulary has no word for, so `capture.stoppedBy` is `null` beside it. `null` means there is no ending to name: no run has finished yet, the run FAILED (read `state` and `errorCode`), or this is a KEYWORD sweep, which records how it ended on keyword_searches.last_run_stopped_by and GET /api/v1/sources reports as `lastRun.stoppedBy`. ⚠ CHANGED IN THIS RELEASE: this field used to be `null` for every ordinary run — beside `capture.stoppedBy: "exhausted"`, and beside a post the provider had cut off at a third of its reactions — so a caller reading only this field saw no ending at all. ⚠️ THOSE TWO FIELDS ARE NOT THE SAME LIST: `lastRun.stoppedBy` answers "how did this keyword sweep end" (budget / credits / exhausted / error / ai_error / team_cap / lead_cap); this one answers how a person, company-page or tracked-post CAPTURE ended. Read from the same job row the rest of this response describes, so it is about the CURRENT run and not about the source's history. PER SYNC, EVERY SYNC: it bounds EACH sync of that one person, company page or post — the capture cap counts lead rows, while billing charges once per new person per source — not the first pull only and not the life of the source, and there is NO ESTIMATE, NO CONFIRMATION GATE AND NO TEAM CEILING behind it: nothing prices a sync before you set the cap, raising or lowering it never needs `confirmSpend`, and the team's `dailyCeiling` does not count a credit of it. ⚠️ A KEYWORD SEARCH'S `creditCap` IS THE OTHER FIELD AND HAS ALL THREE: it is the daily bound on a RECURRING SWEEP, POST /api/v1/keyword/estimate prices it as `estimatedDailyMax`, `confirmSpend` gates a create or a raise with a `409 spend_confirmation_required`, and the team's `dailyCeiling` stops it — and `dailyCeiling` COUNTS KEYWORD SPEND ONLY, so no number of profile syncs can ever reach it. |
lastSyncedAt | string <date-time> | no | |
nextSyncAt | string <date-time> | no | When the next scheduled sync is due (daily cadence). null while a sync is currently running — there is no next run scheduled until the active one finishes; use `state`/`isFinal` to see it is in progress. null for a source that is not ACTIVE — untracked, or paused after repeated not-found errors — always: nothing will run it (untracking parks its schedule, and a stored value is never served for a source that is not active). |
leadsReady | boolean | no | True once leads are ready to read. |
error | string | no | Human-readable failure reason when `state` is `failed`. Owned Cornersight text - it never contains the upstream data provider's raw response. Wording may change; branch on `errorCode`. |
lifecycleEvent | object | no | Whether a sync.completed / sync.failed callback was attempted for this source, and what became of it. `enabled` is true only when syncEvents is on AND a webhook URL is set. `lastDelivery` is the most recent delivery recorded for this source, or null when none has ever been enqueued — so `enabled: true` with `lastDelivery: null` after a finished run means nothing was attempted, which is a different problem from an endpoint that rejected the payload. A delivery enqueued but not yet attempted reads `status: "queued"` with its `queuedAt`, and `stalled: true` once it has waited five minutes without a first attempt. An explicit lead push is reported here too when it is the newest delivery on the source, so `lastDelivery` can be set while `enabled` is false. Read-only; a source with lifecycle events off and no push reports `{ enabled: false, lastDelivery: null }`. |
└enabled | boolean | no | Is this source configured to emit lifecycle events at all? |
└lastDelivery | object | no | The newest delivery recorded for this source: an outbox row, or the newest explicit lead push when that is more recent. `event` (sync.completed | sync.failed | spend.cap_reached | spend.ceiling_reached | lead.detected — the outbox carries the first four, so a `lastDelivery` naming a spend event is not a stray: it is the most recent attempt for this source, whichever event it was; lead.detected means an explicit push), `syncId` (which run it describes — compare against `syncId` above to tell this run from the previous one; null for a push), `status` (queued = enqueued and not yet attempted | pending = attempted, waiting for its next retry | delivering | delivered | failed — and held, for a push whose every lead the webhook's icpOnly filter took), `attempts`, `maxAttempts` (for a push: its leads attempted so far, and its leads in all), `responseStatus` (the HTTP status your endpoint returned on the last attempt; null on a transport failure and for a push), `lastError`, `queuedAt`, `deliveredAt`, `nextAttemptAt` (null once the delivery is terminal), `stalled` (true when still queued, never attempted, five minutes or more after `queuedAt` — a delay on Cornersight's side, not your endpoint), and for a push `pushId` (for GET /api/v1/push/{pushId}) and `counts` (pending, delivering, delivered, failed, held). |
errorCode | "insufficient_enriching_credits" | "not_found" | "page_out_of_range" | "invalid_request" | "provider_rate_limited" | "provider_access_denied" | "provider_unavailable" | "internal_error" | "ai_error" | "search_term_rejected" | null | no | Stable, machine-readable reason for the failure, or `null` when the sync has not failed. The values JobStatus.errorCode uses, plus two that only a KEYWORD sweep produces and that are the CUSTOMER'S to fix: `ai_error` — the search's AI filter failed on the team's own AI setup (a model the provider does not recognise, a revoked key, an exhausted quota); `error` says which and what to change, in the same words as `lastRun.reason`, and `lastRun.aiErrorCode` carries the fine-grained code. `search_term_rejected` — the data provider refused one of the search's terms (HTTP 400); `error` names the term. The same term is refused on every run, so edit the search's keywords — retrying will not help. Before this release both were reported as `internal_error` ("retrying may succeed"), which told the customer the problem was Cornersight's. |
| Status | Meaning | Example error |
|---|---|---|
| 400 | The `username` field is required. | username is required |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The delete is forbidden — either the team's subscription is inactive / its trial has expired, or the profile is a trial profile (trial profiles cannot be deleted; subscribe to a paid plan to manage profiles). | Team subscription not active |
| 404 | No such tracked profile for this team. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 500 | Unexpected server error — the request failed on our side (e.g. a database error), not because of the request itself. On a WRITE endpoint some work may already have been applied before the failure, so prefer re-reading state over blindly retrying, or you can end up with duplicates. | Failed to list leads |
Get sync status for a tracked company page
/api/v1/company/{username}/syncProgress of the background capture sync for a tracked LinkedIn company page. Tracking (saveTrackedProfile=true) queues a staged sync — collecting_posts → collecting_engagements → enriching → completed — and the jobId returned by the enrichment endpoints covers ONLY the profile enrichment, not this sync. Poll this to tell queued / in progress / complete-with-0-leads / failed apart, and stop when `isFinal` is true. THE LIFECYCLE INCLUDES ENRICHMENT, AND SO DOES `isFinal`: capture writes the engagement records, then new people for this source are enriched with firmographics for one credit each; their repeat engagement rows are free, and that second half normally runs on well after the capture run itself has ended. `state` therefore stays `enriching` and `isFinal` stays false while leads are still being enriched and billed — read `enrichment` for the pending / completed / failed / skipped breakdown, and `capture` when all you need to know is that collection finished. Duration is not fixed: the sync is queued, and how long it takes depends on current API load and how many posts and engagements the source has. Leads appear progressively while it runs.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | Public identifier of the tracked personal profile (not a full URL), e.g. demo-profile. |
Responses
200 — Current sync state for the tracked source.
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | |
profileType | "person" | "company" | yes | |
syncId | string <uuid> | no | The background sync run. Null when no sync has ever been queued. |
state | "queued" | "collecting_posts" | "collecting_engagements" | "enriching" | "completed" | "failed" | "paused" | yes | Stage of the source's background lifecycle, capture AND enrichment. Mirrors the dashboard's stages: collecting_posts → collecting_engagements → enriching. `enriching` also covers enrichment that continues after the capture run has finished — the usual case, since enrichment runs off the capture job — so a source stays `enriching` until nothing is left in `enrichment.pending`. `paused` means enriching credits ran out; it resumes automatically when credits return. |
description | string | no | One-line, human-readable explanation of `state`. |
isFinal | boolean | yes | True when the WHOLE lifecycle is over — capture finished and no lead is still queued for enrichment — so nothing further will change without a new run: stop polling. It is NOT set while enrichment is still running and still spending credits. For capture alone, read `capture.isFinal`. |
queuedAt | string <date-time> | no | |
startedAt | string <date-time> | no | |
updatedAt | string <date-time> | no | |
completedAt | string <date-time> | no | When the run finished, capture and enrichment both. Null whenever `isFinal` is false, so it can never read as "done" over leads that are still being enriched. For the capture run's own stamp, read `capture.completedAt`. |
capture | object | yes | The capture half on its own: posts walked and engagement records written, saying nothing about enriching them. `state`, `isFinal` and `completedAt` are exactly what the top-level fields of the same names reported before they were corrected to describe the whole lifecycle, so a caller that only ever needed "collection finished" reads `capture.isFinal` here. `stoppedBy`, `creditsSpent`, `leadRowsCaptured` and `coverage` beside them say WHY the run ended where it did, what it was charged, how many lead rows it wrote and how much of what the provider declared it holds — the answer to "why did this source come back small", which progress counts alone could never give. |
└state | "queued" | "collecting_posts" | "collecting_engagements" | "enriching" | "completed" | "failed" | "paused" | no | The capture run's own stage. |
└isFinal | boolean | no | True once the capture run reached a terminal state, whatever enrichment is still doing. |
└completedAt | string <date-time> | no | When the capture run finished. Enrichment normally continues past this moment. |
└stoppedBy | "exhausted" | "credits" | "capture_empty" | "provider_limit" | null | no | WHY THE CAPTURE RUN ENDED, or `null`. `credits` means the source's own `creditCapPerSync` was reached and the run stopped THERE — there was more to collect, and raising the limit would get it. `exhausted` means the run collected every engagement it found, so a bigger limit would change nothing. `capture_empty` means it collected POSTS and captured NOBODY from them — not a quiet week and not an ending anybody chose: between 12 and 14 September 2026 the upstream engager endpoints answered 200 with no rows for three days and every sync reported `completed` with `error: null` while leads/day went 3,150 to 0. `errorCode` carries the same marker and `error` the sentence "Harvested N posts, captured nobody." A sync that collected NO posts never reports it. `provider_limit` means the DATA PROVIDER stopped serving a post's reactions with at least one full page (50) of them still declared, and no cap was the reason: the capture holds materially fewer people than the post declares and NO LIMIT WILL GET THE REST, because the provider will not serve them. Measured 29 September 2026: a post declaring 3,490 reactions stopped at about 1,100 people (22 pages of ~50, then only empty pages, every one still declaring 3,490). `coverage` beside it gives the declared and captured counts. The rule counts the rows the provider SERVED, reactions it sends with no identity included, against the declared total, so a post served in full still ends `exhausted` (418 declared, 404 identifiable and 14 anonymous is complete); it is the rule POST /api/v1/post/reactions uses for `exhausted: false`, so the two never disagree about one post. A run the credit cap stopped reports `credits`, never this. Before this field the two were the SAME RESPONSE: "collected 100 because that was everything" and "collected 100 because 100 was the cap" both arrived as progress counts with nothing to tell them apart. THE WORDS ARE `lastRun.stoppedBy`'s ON GET /api/v1/sources, deliberately — the keyword side closed this same gap first, and a second vocabulary for one question is how a caller ends up writing two branches for one fact. ⚠ `null` IS FOUR DIFFERENT THINGS AND ALL FOUR ARE HONEST: the first sync has not finished yet; the run FAILED (neither word is true of a run that broke — read `state` and `errorCode`); the source was UNTRACKED mid-run, an ending this vocabulary has no word for and which the TOP-LEVEL `stoppedBy` reports as `untracked`; or this is a KEYWORD search, whose sweep records its ending on `lastRun.stoppedBy` at GET /api/v1/sources and never here. The TOP-LEVEL `stoppedBy` on this same response names the SAME ending, spelling the cap `credit_cap` and an untracked run `untracked`. |
└creditsSpent | integer | no | WHAT THIS RUN WAS CHARGED, in credits, or `null` — the same ledger GET /api/v1/credits/usage totals, attributed to this run: the credits charged for the leads it created (one per NEW person per source — a repeat engager's row, and a lead whose enrichment failed, cost nothing), the posts a posts-only watch bought (one credit each), or, for a keyword search, what its sweep spent (the number `lastRun.creditsSpent` reports). ⚠ CHANGED IN THIS RELEASE: it used to be the lead-ROW count, which read as an overspend (46 rows beside a `creditCap` of 30 that charged 30) and as free for a paid posts-only run (0). That count is now `leadRowsCaptured`. Enrichment charges a run's leads AFTER the capture ends, so while `isFinal` is false this is the charge SO FAR; it is settled when `isFinal` is true. PRESENT ON A FAILED RUN TOO, unlike `stoppedBy`: the leads a run wrote before it broke are still enriched and charged. `null` until the capture reaches a terminal state, and `null` — never a guessed `0` — when the charge could not be read. A `0` is a measurement. |
└leadRowsCaptured | integer | no | The lead ROWS this run wrote — what `creditsSpent` reported before this release. NOT what it cost: billing charges once per new person per source, so a repeat engager writes a row for free and a posts-only watch writes none. `creditCapPerSync` caps these rows. `null` until the capture reaches a terminal state and for a run that predates the counter; present on a failed run too. A `0` is a measurement. |
└coverage | object | no | DECLARED BESIDE CAPTURED: the reactions + comments the provider declared on the posts this run swept, and the leads this source now holds on those same posts — so a post the provider cut off reads `{"declared": 3690, "captured": 1147}` instead of being left to be inferred. `captured` is cumulative (a re-sync that adds 5 people to a post it already holds 1,045 of reports 1,050) and counts PEOPLE, deduplicated, while `declared` counts engagements, so even a post captured in full reads a little under (reactions with no identity, company pages, one person who both reacted and commented). The gap that matters is the one `stoppedBy: "provider_limit"` names. `null` until the capture is over, for a run recorded before this field, for a keyword search or a posts-only watch (which record no engagement coverage), and whenever either number is missing — half a pair is never served. |
└declared | integer | yes | |
└captured | integer | yes | |
enrichment | object | yes | The source's leads by enrichment outcome. The four buckets partition `total`, so the gap between captured and enriched leads is always attributable. THIS IS WHERE THE CREDITS GO: one credit per newly enriched person per source; repeat engagement rows are free, so spend continues after capture completes. A run's billing is settled and its downstream processing done when `pending` reaches 0 — the same moment `isFinal` becomes true, `leadsReady` flips, and the run's `lead.detected` webhooks and provider integrations fire. |
└total | integer | no | All leads held for this source, across runs. The same number as `progress.leadsTotal`. |
└pending | integer | no | Queued or in flight: work still owed, and still to be billed. Above 0 means this source is not finished, whatever the capture run says. |
└completed | integer | no | Enriched: carries firmographics. This is a row count, not billed credits; repeats of a person already charged for this source are free. For a source in raw mode (`enrichLeads: false`) it counts the leads captured raw and charged instead — done, never enriched, carrying no firmographics, and read with GET /api/v1/leads/raw. |
└failed | integer | no | Enrichment attempts exhausted. Terminal and never charged — these are reported rather than left holding the lifecycle open. |
└skipped | integer | no | Terminal with no data to add: the provider returned nothing for the person, or the team has no chargeable enriching plan. Never charged. |
progress | object | yes | |
└postsCollected | integer | no | Posts found on the source in this run. |
└engagementsCaptured | integer | no | Engagements (likes + comments) captured in this run. |
└leadsTotal | integer | no | All leads currently held for this source, across runs. |
└leadsEnriched | integer | no | Of those, how many carry enriched firmographics. Unchanged, and equal to `enrichment.completed`; `enrichment` accounts for the rest. For a source in raw mode (`enrichLeads: false`) it counts the leads captured raw and charged, which carry none. |
stoppedBy | "credit_cap" | "exhausted" | "capture_empty" | "provider_limit" | "untracked" | null | no | HOW THIS RUN ENDED — the SAME ending `capture.stoppedBy` names, so the two fields never disagree. `credit_cap` means the source's own per-sync credit limit (tracked_profiles.credit_cap, reported as `creditCapPerSync` on GET /api/v1/sources) was reached and the run stopped there — an ORDINARY ending, not a failure: `state` stays `completed`, `errorCode` is null, nothing broke. It is the ending `capture.stoppedBy` spells `credits`: both spellings were published first and callers branch on each, so neither is renamed. `exhausted`, `capture_empty` and `provider_limit` mean exactly what they mean on `capture.stoppedBy`. `untracked` means the source was untracked while the run was in flight and the sweep was abandoned — an ending the capture vocabulary has no word for, so `capture.stoppedBy` is `null` beside it. `null` means there is no ending to name: no run has finished yet, the run FAILED (read `state` and `errorCode`), or this is a KEYWORD sweep, which records how it ended on keyword_searches.last_run_stopped_by and GET /api/v1/sources reports as `lastRun.stoppedBy`. ⚠ CHANGED IN THIS RELEASE: this field used to be `null` for every ordinary run — beside `capture.stoppedBy: "exhausted"`, and beside a post the provider had cut off at a third of its reactions — so a caller reading only this field saw no ending at all. ⚠️ THOSE TWO FIELDS ARE NOT THE SAME LIST: `lastRun.stoppedBy` answers "how did this keyword sweep end" (budget / credits / exhausted / error / ai_error / team_cap / lead_cap); this one answers how a person, company-page or tracked-post CAPTURE ended. Read from the same job row the rest of this response describes, so it is about the CURRENT run and not about the source's history. PER SYNC, EVERY SYNC: it bounds EACH sync of that one person, company page or post — the capture cap counts lead rows, while billing charges once per new person per source — not the first pull only and not the life of the source, and there is NO ESTIMATE, NO CONFIRMATION GATE AND NO TEAM CEILING behind it: nothing prices a sync before you set the cap, raising or lowering it never needs `confirmSpend`, and the team's `dailyCeiling` does not count a credit of it. ⚠️ A KEYWORD SEARCH'S `creditCap` IS THE OTHER FIELD AND HAS ALL THREE: it is the daily bound on a RECURRING SWEEP, POST /api/v1/keyword/estimate prices it as `estimatedDailyMax`, `confirmSpend` gates a create or a raise with a `409 spend_confirmation_required`, and the team's `dailyCeiling` stops it — and `dailyCeiling` COUNTS KEYWORD SPEND ONLY, so no number of profile syncs can ever reach it. |
lastSyncedAt | string <date-time> | no | |
nextSyncAt | string <date-time> | no | When the next scheduled sync is due (daily cadence). null while a sync is currently running — there is no next run scheduled until the active one finishes; use `state`/`isFinal` to see it is in progress. null for a source that is not ACTIVE — untracked, or paused after repeated not-found errors — always: nothing will run it (untracking parks its schedule, and a stored value is never served for a source that is not active). |
leadsReady | boolean | no | True once leads are ready to read. |
error | string | no | Human-readable failure reason when `state` is `failed`. Owned Cornersight text - it never contains the upstream data provider's raw response. Wording may change; branch on `errorCode`. |
lifecycleEvent | object | no | Whether a sync.completed / sync.failed callback was attempted for this source, and what became of it. `enabled` is true only when syncEvents is on AND a webhook URL is set. `lastDelivery` is the most recent delivery recorded for this source, or null when none has ever been enqueued — so `enabled: true` with `lastDelivery: null` after a finished run means nothing was attempted, which is a different problem from an endpoint that rejected the payload. A delivery enqueued but not yet attempted reads `status: "queued"` with its `queuedAt`, and `stalled: true` once it has waited five minutes without a first attempt. An explicit lead push is reported here too when it is the newest delivery on the source, so `lastDelivery` can be set while `enabled` is false. Read-only; a source with lifecycle events off and no push reports `{ enabled: false, lastDelivery: null }`. |
└enabled | boolean | no | Is this source configured to emit lifecycle events at all? |
└lastDelivery | object | no | The newest delivery recorded for this source: an outbox row, or the newest explicit lead push when that is more recent. `event` (sync.completed | sync.failed | spend.cap_reached | spend.ceiling_reached | lead.detected — the outbox carries the first four, so a `lastDelivery` naming a spend event is not a stray: it is the most recent attempt for this source, whichever event it was; lead.detected means an explicit push), `syncId` (which run it describes — compare against `syncId` above to tell this run from the previous one; null for a push), `status` (queued = enqueued and not yet attempted | pending = attempted, waiting for its next retry | delivering | delivered | failed — and held, for a push whose every lead the webhook's icpOnly filter took), `attempts`, `maxAttempts` (for a push: its leads attempted so far, and its leads in all), `responseStatus` (the HTTP status your endpoint returned on the last attempt; null on a transport failure and for a push), `lastError`, `queuedAt`, `deliveredAt`, `nextAttemptAt` (null once the delivery is terminal), `stalled` (true when still queued, never attempted, five minutes or more after `queuedAt` — a delay on Cornersight's side, not your endpoint), and for a push `pushId` (for GET /api/v1/push/{pushId}) and `counts` (pending, delivering, delivered, failed, held). |
errorCode | "insufficient_enriching_credits" | "not_found" | "page_out_of_range" | "invalid_request" | "provider_rate_limited" | "provider_access_denied" | "provider_unavailable" | "internal_error" | "ai_error" | "search_term_rejected" | null | no | Stable, machine-readable reason for the failure, or `null` when the sync has not failed. The values JobStatus.errorCode uses, plus two that only a KEYWORD sweep produces and that are the CUSTOMER'S to fix: `ai_error` — the search's AI filter failed on the team's own AI setup (a model the provider does not recognise, a revoked key, an exhausted quota); `error` says which and what to change, in the same words as `lastRun.reason`, and `lastRun.aiErrorCode` carries the fine-grained code. `search_term_rejected` — the data provider refused one of the search's terms (HTTP 400); `error` names the term. The same term is refused on every run, so edit the search's keywords — retrying will not help. Before this release both were reported as `internal_error` ("retrying may succeed"), which told the customer the problem was Cornersight's. |
| Status | Meaning | Example error |
|---|---|---|
| 400 | The `username` field is required. | username is required |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The delete is forbidden — either the team's subscription is inactive / its trial has expired, or the profile is a trial profile (trial profiles cannot be deleted; subscribe to a paid plan to manage profiles). | Team subscription not active |
| 404 | No such tracked company page for this team. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 500 | Unexpected server error — the request failed on our side (e.g. a database error), not because of the request itself. On a WRITE endpoint some work may already have been applied before the failure, so prefer re-reading state over blindly retrying, or you can end up with duplicates. | Failed to list leads |
Check sync status for a tracked keyword search
/api/v1/keyword/{id}/syncCheck a tracked KEYWORD SEARCH's background capture sweep: stage, progress counts, and whether it has finished. Addressed by SOURCE ID, not by keyword text: a keyword search's identifier in GET /api/v1/sources is `id`, while its `username` there is the free-text terms it searches for. A keyword sweep writes a sync_jobs row exactly as a profile sync does, so the state, phase, counters and lead totals here mean the same thing for it as for any other source. THE LIFECYCLE INCLUDES ENRICHMENT, AND SO DOES `isFinal`: capture writes the engagement records, then new people for this source are enriched with firmographics for one credit each; their repeat engagement rows are free, and that second half normally runs on well after the capture run itself has ended. `state` therefore stays `enriching` and `isFinal` stays false while leads are still being enriched and billed — read `enrichment` for the pending / completed / failed / skipped breakdown, and `capture` when all you need to know is that collection finished.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | string <uuid> | yes | The keyword search's source id, from GET /api/v1/sources. A keyword search is addressed by id, not by its keyword text. |
Responses
200 — Current sync state for the tracked source.
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | |
profileType | "keyword" | yes | |
syncId | string <uuid> | no | The background sync run. Null when no sync has ever been queued. |
state | "queued" | "collecting_posts" | "collecting_engagements" | "enriching" | "completed" | "failed" | "paused" | yes | Stage of the source's background lifecycle, capture AND enrichment. Mirrors the dashboard's stages: collecting_posts → collecting_engagements → enriching. `enriching` also covers enrichment that continues after the capture run has finished — the usual case, since enrichment runs off the capture job — so a source stays `enriching` until nothing is left in `enrichment.pending`. `paused` means enriching credits ran out; it resumes automatically when credits return. |
description | string | no | One-line, human-readable explanation of `state`. |
isFinal | boolean | yes | True when the WHOLE lifecycle is over — capture finished and no lead is still queued for enrichment — so nothing further will change without a new run: stop polling. It is NOT set while enrichment is still running and still spending credits. For capture alone, read `capture.isFinal`. |
queuedAt | string <date-time> | no | |
startedAt | string <date-time> | no | |
updatedAt | string <date-time> | no | |
completedAt | string <date-time> | no | When the run finished, capture and enrichment both. Null whenever `isFinal` is false, so it can never read as "done" over leads that are still being enriched. For the capture run's own stamp, read `capture.completedAt`. |
capture | object | yes | The capture half on its own: posts walked and engagement records written, saying nothing about enriching them. `state`, `isFinal` and `completedAt` are exactly what the top-level fields of the same names reported before they were corrected to describe the whole lifecycle, so a caller that only ever needed "collection finished" reads `capture.isFinal` here. `stoppedBy`, `creditsSpent`, `leadRowsCaptured` and `coverage` beside them say WHY the run ended where it did, what it was charged, how many lead rows it wrote and how much of what the provider declared it holds — the answer to "why did this source come back small", which progress counts alone could never give. |
└state | "queued" | "collecting_posts" | "collecting_engagements" | "enriching" | "completed" | "failed" | "paused" | no | The capture run's own stage. |
└isFinal | boolean | no | True once the capture run reached a terminal state, whatever enrichment is still doing. |
└completedAt | string <date-time> | no | When the capture run finished. Enrichment normally continues past this moment. |
└stoppedBy | "exhausted" | "credits" | "capture_empty" | "provider_limit" | null | no | WHY THE CAPTURE RUN ENDED, or `null`. `credits` means the source's own `creditCapPerSync` was reached and the run stopped THERE — there was more to collect, and raising the limit would get it. `exhausted` means the run collected every engagement it found, so a bigger limit would change nothing. `capture_empty` means it collected POSTS and captured NOBODY from them — not a quiet week and not an ending anybody chose: between 12 and 14 September 2026 the upstream engager endpoints answered 200 with no rows for three days and every sync reported `completed` with `error: null` while leads/day went 3,150 to 0. `errorCode` carries the same marker and `error` the sentence "Harvested N posts, captured nobody." A sync that collected NO posts never reports it. `provider_limit` means the DATA PROVIDER stopped serving a post's reactions with at least one full page (50) of them still declared, and no cap was the reason: the capture holds materially fewer people than the post declares and NO LIMIT WILL GET THE REST, because the provider will not serve them. Measured 29 September 2026: a post declaring 3,490 reactions stopped at about 1,100 people (22 pages of ~50, then only empty pages, every one still declaring 3,490). `coverage` beside it gives the declared and captured counts. The rule counts the rows the provider SERVED, reactions it sends with no identity included, against the declared total, so a post served in full still ends `exhausted` (418 declared, 404 identifiable and 14 anonymous is complete); it is the rule POST /api/v1/post/reactions uses for `exhausted: false`, so the two never disagree about one post. A run the credit cap stopped reports `credits`, never this. Before this field the two were the SAME RESPONSE: "collected 100 because that was everything" and "collected 100 because 100 was the cap" both arrived as progress counts with nothing to tell them apart. THE WORDS ARE `lastRun.stoppedBy`'s ON GET /api/v1/sources, deliberately — the keyword side closed this same gap first, and a second vocabulary for one question is how a caller ends up writing two branches for one fact. ⚠ `null` IS FOUR DIFFERENT THINGS AND ALL FOUR ARE HONEST: the first sync has not finished yet; the run FAILED (neither word is true of a run that broke — read `state` and `errorCode`); the source was UNTRACKED mid-run, an ending this vocabulary has no word for and which the TOP-LEVEL `stoppedBy` reports as `untracked`; or this is a KEYWORD search, whose sweep records its ending on `lastRun.stoppedBy` at GET /api/v1/sources and never here. The TOP-LEVEL `stoppedBy` on this same response names the SAME ending, spelling the cap `credit_cap` and an untracked run `untracked`. |
└creditsSpent | integer | no | WHAT THIS RUN WAS CHARGED, in credits, or `null` — the same ledger GET /api/v1/credits/usage totals, attributed to this run: the credits charged for the leads it created (one per NEW person per source — a repeat engager's row, and a lead whose enrichment failed, cost nothing), the posts a posts-only watch bought (one credit each), or, for a keyword search, what its sweep spent (the number `lastRun.creditsSpent` reports). ⚠ CHANGED IN THIS RELEASE: it used to be the lead-ROW count, which read as an overspend (46 rows beside a `creditCap` of 30 that charged 30) and as free for a paid posts-only run (0). That count is now `leadRowsCaptured`. Enrichment charges a run's leads AFTER the capture ends, so while `isFinal` is false this is the charge SO FAR; it is settled when `isFinal` is true. PRESENT ON A FAILED RUN TOO, unlike `stoppedBy`: the leads a run wrote before it broke are still enriched and charged. `null` until the capture reaches a terminal state, and `null` — never a guessed `0` — when the charge could not be read. A `0` is a measurement. |
└leadRowsCaptured | integer | no | The lead ROWS this run wrote — what `creditsSpent` reported before this release. NOT what it cost: billing charges once per new person per source, so a repeat engager writes a row for free and a posts-only watch writes none. `creditCapPerSync` caps these rows. `null` until the capture reaches a terminal state and for a run that predates the counter; present on a failed run too. A `0` is a measurement. |
└coverage | object | no | DECLARED BESIDE CAPTURED: the reactions + comments the provider declared on the posts this run swept, and the leads this source now holds on those same posts — so a post the provider cut off reads `{"declared": 3690, "captured": 1147}` instead of being left to be inferred. `captured` is cumulative (a re-sync that adds 5 people to a post it already holds 1,045 of reports 1,050) and counts PEOPLE, deduplicated, while `declared` counts engagements, so even a post captured in full reads a little under (reactions with no identity, company pages, one person who both reacted and commented). The gap that matters is the one `stoppedBy: "provider_limit"` names. `null` until the capture is over, for a run recorded before this field, for a keyword search or a posts-only watch (which record no engagement coverage), and whenever either number is missing — half a pair is never served. |
└declared | integer | yes | |
└captured | integer | yes | |
enrichment | object | yes | The source's leads by enrichment outcome. The four buckets partition `total`, so the gap between captured and enriched leads is always attributable. THIS IS WHERE THE CREDITS GO: one credit per newly enriched person per source; repeat engagement rows are free, so spend continues after capture completes. A run's billing is settled and its downstream processing done when `pending` reaches 0 — the same moment `isFinal` becomes true, `leadsReady` flips, and the run's `lead.detected` webhooks and provider integrations fire. |
└total | integer | no | All leads held for this source, across runs. The same number as `progress.leadsTotal`. |
└pending | integer | no | Queued or in flight: work still owed, and still to be billed. Above 0 means this source is not finished, whatever the capture run says. |
└completed | integer | no | Enriched: carries firmographics. This is a row count, not billed credits; repeats of a person already charged for this source are free. For a source in raw mode (`enrichLeads: false`) it counts the leads captured raw and charged instead — done, never enriched, carrying no firmographics, and read with GET /api/v1/leads/raw. |
└failed | integer | no | Enrichment attempts exhausted. Terminal and never charged — these are reported rather than left holding the lifecycle open. |
└skipped | integer | no | Terminal with no data to add: the provider returned nothing for the person, or the team has no chargeable enriching plan. Never charged. |
progress | object | yes | |
└postsCollected | integer | no | Posts found on the source in this run. |
└engagementsCaptured | integer | no | Engagements (likes + comments) captured in this run. |
└leadsTotal | integer | no | All leads currently held for this source, across runs. |
└leadsEnriched | integer | no | Of those, how many carry enriched firmographics. Unchanged, and equal to `enrichment.completed`; `enrichment` accounts for the rest. For a source in raw mode (`enrichLeads: false`) it counts the leads captured raw and charged, which carry none. |
stoppedBy | "credit_cap" | "exhausted" | "capture_empty" | "provider_limit" | "untracked" | null | no | HOW THIS RUN ENDED — the SAME ending `capture.stoppedBy` names, so the two fields never disagree. `credit_cap` means the source's own per-sync credit limit (tracked_profiles.credit_cap, reported as `creditCapPerSync` on GET /api/v1/sources) was reached and the run stopped there — an ORDINARY ending, not a failure: `state` stays `completed`, `errorCode` is null, nothing broke. It is the ending `capture.stoppedBy` spells `credits`: both spellings were published first and callers branch on each, so neither is renamed. `exhausted`, `capture_empty` and `provider_limit` mean exactly what they mean on `capture.stoppedBy`. `untracked` means the source was untracked while the run was in flight and the sweep was abandoned — an ending the capture vocabulary has no word for, so `capture.stoppedBy` is `null` beside it. `null` means there is no ending to name: no run has finished yet, the run FAILED (read `state` and `errorCode`), or this is a KEYWORD sweep, which records how it ended on keyword_searches.last_run_stopped_by and GET /api/v1/sources reports as `lastRun.stoppedBy`. ⚠ CHANGED IN THIS RELEASE: this field used to be `null` for every ordinary run — beside `capture.stoppedBy: "exhausted"`, and beside a post the provider had cut off at a third of its reactions — so a caller reading only this field saw no ending at all. ⚠️ THOSE TWO FIELDS ARE NOT THE SAME LIST: `lastRun.stoppedBy` answers "how did this keyword sweep end" (budget / credits / exhausted / error / ai_error / team_cap / lead_cap); this one answers how a person, company-page or tracked-post CAPTURE ended. Read from the same job row the rest of this response describes, so it is about the CURRENT run and not about the source's history. PER SYNC, EVERY SYNC: it bounds EACH sync of that one person, company page or post — the capture cap counts lead rows, while billing charges once per new person per source — not the first pull only and not the life of the source, and there is NO ESTIMATE, NO CONFIRMATION GATE AND NO TEAM CEILING behind it: nothing prices a sync before you set the cap, raising or lowering it never needs `confirmSpend`, and the team's `dailyCeiling` does not count a credit of it. ⚠️ A KEYWORD SEARCH'S `creditCap` IS THE OTHER FIELD AND HAS ALL THREE: it is the daily bound on a RECURRING SWEEP, POST /api/v1/keyword/estimate prices it as `estimatedDailyMax`, `confirmSpend` gates a create or a raise with a `409 spend_confirmation_required`, and the team's `dailyCeiling` stops it — and `dailyCeiling` COUNTS KEYWORD SPEND ONLY, so no number of profile syncs can ever reach it. |
lastSyncedAt | string <date-time> | no | |
nextSyncAt | string <date-time> | no | When the next scheduled sync is due (daily cadence). null while a sync is currently running — there is no next run scheduled until the active one finishes; use `state`/`isFinal` to see it is in progress. null for a source that is not ACTIVE — untracked, or paused after repeated not-found errors — always: nothing will run it (untracking parks its schedule, and a stored value is never served for a source that is not active). |
leadsReady | boolean | no | True once leads are ready to read. |
error | string | no | Human-readable failure reason when `state` is `failed`. Owned Cornersight text - it never contains the upstream data provider's raw response. Wording may change; branch on `errorCode`. |
lifecycleEvent | object | no | Whether a sync.completed / sync.failed callback was attempted for this source, and what became of it. `enabled` is true only when syncEvents is on AND a webhook URL is set. `lastDelivery` is the most recent delivery recorded for this source, or null when none has ever been enqueued — so `enabled: true` with `lastDelivery: null` after a finished run means nothing was attempted, which is a different problem from an endpoint that rejected the payload. A delivery enqueued but not yet attempted reads `status: "queued"` with its `queuedAt`, and `stalled: true` once it has waited five minutes without a first attempt. An explicit lead push is reported here too when it is the newest delivery on the source, so `lastDelivery` can be set while `enabled` is false. Read-only; a source with lifecycle events off and no push reports `{ enabled: false, lastDelivery: null }`. |
└enabled | boolean | no | Is this source configured to emit lifecycle events at all? |
└lastDelivery | object | no | The newest delivery recorded for this source: an outbox row, or the newest explicit lead push when that is more recent. `event` (sync.completed | sync.failed | spend.cap_reached | spend.ceiling_reached | lead.detected — the outbox carries the first four, so a `lastDelivery` naming a spend event is not a stray: it is the most recent attempt for this source, whichever event it was; lead.detected means an explicit push), `syncId` (which run it describes — compare against `syncId` above to tell this run from the previous one; null for a push), `status` (queued = enqueued and not yet attempted | pending = attempted, waiting for its next retry | delivering | delivered | failed — and held, for a push whose every lead the webhook's icpOnly filter took), `attempts`, `maxAttempts` (for a push: its leads attempted so far, and its leads in all), `responseStatus` (the HTTP status your endpoint returned on the last attempt; null on a transport failure and for a push), `lastError`, `queuedAt`, `deliveredAt`, `nextAttemptAt` (null once the delivery is terminal), `stalled` (true when still queued, never attempted, five minutes or more after `queuedAt` — a delay on Cornersight's side, not your endpoint), and for a push `pushId` (for GET /api/v1/push/{pushId}) and `counts` (pending, delivering, delivered, failed, held). |
errorCode | "insufficient_enriching_credits" | "not_found" | "page_out_of_range" | "invalid_request" | "provider_rate_limited" | "provider_access_denied" | "provider_unavailable" | "internal_error" | "ai_error" | "search_term_rejected" | null | no | Stable, machine-readable reason for the failure, or `null` when the sync has not failed. The values JobStatus.errorCode uses, plus two that only a KEYWORD sweep produces and that are the CUSTOMER'S to fix: `ai_error` — the search's AI filter failed on the team's own AI setup (a model the provider does not recognise, a revoked key, an exhausted quota); `error` says which and what to change, in the same words as `lastRun.reason`, and `lastRun.aiErrorCode` carries the fine-grained code. `search_term_rejected` — the data provider refused one of the search's terms (HTTP 400); `error` names the term. The same term is refused on every run, so edit the search's keywords — retrying will not help. Before this release both were reported as `internal_error` ("retrying may succeed"), which told the customer the problem was Cornersight's. |
| Status | Meaning | Example error |
|---|---|---|
| 400 | The path segment is not a source id, or the body is invalid. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The delete is forbidden — either the team's subscription is inactive / its trial has expired, or the profile is a trial profile (trial profiles cannot be deleted; subscribe to a paid plan to manage profiles). | Team subscription not active |
| 404 | No keyword search with that id for this team. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 500 | Unexpected server error — the request failed on our side (e.g. a database error), not because of the request itself. On a WRITE endpoint some work may already have been applied before the failure, so prefer re-reading state over blindly retrying, or you can end up with duplicates. | Failed to list leads |
Check sync status for any tracked source, by source id
/api/v1/sources/{id}/syncCheck ANY tracked source's background capture sync: stage, progress counts, and whether it has finished. ADDRESSED BY SOURCE ID, AND IT WORKS FOR EVERY KIND OF SOURCE — a person, a company page, a TRACKED POST or a KEYWORD SEARCH. The per-kind routes are keyed by whatever identifier that kind happens to have (/api/v1/{profile|company}/{username}/... by LinkedIn handle, /api/v1/keyword/{id}/... by source id), which is why a tracked post — whose identifier is an activity URN — had no route at all. It was never the SETTING that was missing: a tracked post is a tracked_profiles row like any other, its webhook fires through the same path, its leads are scored against the same ICP rules, and POST /api/v1/post/track has always returned a `syncId` from a real sync job. Only the addressing was. THIS IS HOW YOU FOLLOW A TRACKED POST. POST /api/v1/post/track answers with a `syncId` — a real sync_jobs row, queued the same way a profile's first sync is — and until this route existed there was nothing to hand that id to, so "is my post capturing?" was unanswerable from the API. THE BODY IS THE SAME LIFECYCLE OBJECT the per-kind /sync routes return, field for field, because it is the same code: what differs between the routes is how you addressed the source and the identity fields you get back, never what "finished" means. THE LIFECYCLE INCLUDES ENRICHMENT, AND SO DOES `isFinal`: capture writes the engagement records, then new people for this source are enriched with firmographics for one credit each; their repeat engagement rows are free, and that second half normally runs on well after the capture itself has ended. `state` therefore stays `enriching` and `isFinal` stays false while leads are still being enriched and billed — read `enrichment` for the pending / completed / failed / skipped breakdown, and `capture` when all you need to know is that collection finished.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | string <uuid> | yes | The SOURCE id, as GET /api/v1/sources returns it in each source's `id`. Every kind of source has one — that is the whole point of this family: a person and a company page are also reachable by handle, a keyword search's handle is its free-text search terms, and a tracked post's is an activity URN, so the id is the only identifier all four share. |
Responses
200 — The source's current sync lifecycle.
| Field | Type | Required | Description |
|---|---|---|---|
sourceId | string <uuid> | yes | The source id you addressed — the same value GET /api/v1/sources reports as `id`. |
type | "person" | "company" | "post" | "keyword" | yes | The kind of source this id turned out to be, spelled exactly as GET /api/v1/sources spells it. You do not have to know the kind to call these routes; this is how you learn it. |
username | string | yes | The row's stored label, which means something different per kind: a LinkedIn handle for a person or company page, the activity URN for a tracked post, and the joined search terms for a keyword search. Reported, never used to address these routes. |
syncId | string <uuid> | no | The background sync run. Null when no sync has ever been queued. |
state | "queued" | "collecting_posts" | "collecting_engagements" | "enriching" | "completed" | "failed" | "paused" | yes | Stage of the source's background lifecycle, capture AND enrichment. Mirrors the dashboard's stages: collecting_posts → collecting_engagements → enriching. `enriching` also covers enrichment that continues after the capture run has finished — the usual case, since enrichment runs off the capture job — so a source stays `enriching` until nothing is left in `enrichment.pending`. `paused` means enriching credits ran out; it resumes automatically when credits return. |
description | string | no | One-line, human-readable explanation of `state`. |
isFinal | boolean | yes | True when the WHOLE lifecycle is over — capture finished and no lead is still queued for enrichment — so nothing further will change without a new run: stop polling. It is NOT set while enrichment is still running and still spending credits. For capture alone, read `capture.isFinal`. |
queuedAt | string <date-time> | no | |
startedAt | string <date-time> | no | |
updatedAt | string <date-time> | no | |
completedAt | string <date-time> | no | When the run finished, capture and enrichment both. Null whenever `isFinal` is false, so it can never read as "done" over leads that are still being enriched. For the capture run's own stamp, read `capture.completedAt`. |
capture | object | yes | The capture half on its own: posts walked and engagement records written, saying nothing about enriching them. `state`, `isFinal` and `completedAt` are exactly what the top-level fields of the same names reported before they were corrected to describe the whole lifecycle, so a caller that only ever needed "collection finished" reads `capture.isFinal` here. `stoppedBy`, `creditsSpent`, `leadRowsCaptured` and `coverage` beside them say WHY the run ended where it did, what it was charged, how many lead rows it wrote and how much of what the provider declared it holds — the answer to "why did this source come back small", which progress counts alone could never give. |
└state | "queued" | "collecting_posts" | "collecting_engagements" | "enriching" | "completed" | "failed" | "paused" | no | The capture run's own stage. |
└isFinal | boolean | no | True once the capture run reached a terminal state, whatever enrichment is still doing. |
└completedAt | string <date-time> | no | When the capture run finished. Enrichment normally continues past this moment. |
└stoppedBy | "exhausted" | "credits" | "capture_empty" | "provider_limit" | null | no | WHY THE CAPTURE RUN ENDED, or `null`. `credits` means the source's own `creditCapPerSync` was reached and the run stopped THERE — there was more to collect, and raising the limit would get it. `exhausted` means the run collected every engagement it found, so a bigger limit would change nothing. `capture_empty` means it collected POSTS and captured NOBODY from them — not a quiet week and not an ending anybody chose: between 12 and 14 September 2026 the upstream engager endpoints answered 200 with no rows for three days and every sync reported `completed` with `error: null` while leads/day went 3,150 to 0. `errorCode` carries the same marker and `error` the sentence "Harvested N posts, captured nobody." A sync that collected NO posts never reports it. `provider_limit` means the DATA PROVIDER stopped serving a post's reactions with at least one full page (50) of them still declared, and no cap was the reason: the capture holds materially fewer people than the post declares and NO LIMIT WILL GET THE REST, because the provider will not serve them. Measured 29 September 2026: a post declaring 3,490 reactions stopped at about 1,100 people (22 pages of ~50, then only empty pages, every one still declaring 3,490). `coverage` beside it gives the declared and captured counts. The rule counts the rows the provider SERVED, reactions it sends with no identity included, against the declared total, so a post served in full still ends `exhausted` (418 declared, 404 identifiable and 14 anonymous is complete); it is the rule POST /api/v1/post/reactions uses for `exhausted: false`, so the two never disagree about one post. A run the credit cap stopped reports `credits`, never this. Before this field the two were the SAME RESPONSE: "collected 100 because that was everything" and "collected 100 because 100 was the cap" both arrived as progress counts with nothing to tell them apart. THE WORDS ARE `lastRun.stoppedBy`'s ON GET /api/v1/sources, deliberately — the keyword side closed this same gap first, and a second vocabulary for one question is how a caller ends up writing two branches for one fact. ⚠ `null` IS FOUR DIFFERENT THINGS AND ALL FOUR ARE HONEST: the first sync has not finished yet; the run FAILED (neither word is true of a run that broke — read `state` and `errorCode`); the source was UNTRACKED mid-run, an ending this vocabulary has no word for and which the TOP-LEVEL `stoppedBy` reports as `untracked`; or this is a KEYWORD search, whose sweep records its ending on `lastRun.stoppedBy` at GET /api/v1/sources and never here. The TOP-LEVEL `stoppedBy` on this same response names the SAME ending, spelling the cap `credit_cap` and an untracked run `untracked`. |
└creditsSpent | integer | no | WHAT THIS RUN WAS CHARGED, in credits, or `null` — the same ledger GET /api/v1/credits/usage totals, attributed to this run: the credits charged for the leads it created (one per NEW person per source — a repeat engager's row, and a lead whose enrichment failed, cost nothing), the posts a posts-only watch bought (one credit each), or, for a keyword search, what its sweep spent (the number `lastRun.creditsSpent` reports). ⚠ CHANGED IN THIS RELEASE: it used to be the lead-ROW count, which read as an overspend (46 rows beside a `creditCap` of 30 that charged 30) and as free for a paid posts-only run (0). That count is now `leadRowsCaptured`. Enrichment charges a run's leads AFTER the capture ends, so while `isFinal` is false this is the charge SO FAR; it is settled when `isFinal` is true. PRESENT ON A FAILED RUN TOO, unlike `stoppedBy`: the leads a run wrote before it broke are still enriched and charged. `null` until the capture reaches a terminal state, and `null` — never a guessed `0` — when the charge could not be read. A `0` is a measurement. |
└leadRowsCaptured | integer | no | The lead ROWS this run wrote — what `creditsSpent` reported before this release. NOT what it cost: billing charges once per new person per source, so a repeat engager writes a row for free and a posts-only watch writes none. `creditCapPerSync` caps these rows. `null` until the capture reaches a terminal state and for a run that predates the counter; present on a failed run too. A `0` is a measurement. |
└coverage | object | no | DECLARED BESIDE CAPTURED: the reactions + comments the provider declared on the posts this run swept, and the leads this source now holds on those same posts — so a post the provider cut off reads `{"declared": 3690, "captured": 1147}` instead of being left to be inferred. `captured` is cumulative (a re-sync that adds 5 people to a post it already holds 1,045 of reports 1,050) and counts PEOPLE, deduplicated, while `declared` counts engagements, so even a post captured in full reads a little under (reactions with no identity, company pages, one person who both reacted and commented). The gap that matters is the one `stoppedBy: "provider_limit"` names. `null` until the capture is over, for a run recorded before this field, for a keyword search or a posts-only watch (which record no engagement coverage), and whenever either number is missing — half a pair is never served. |
└declared | integer | yes | |
└captured | integer | yes | |
enrichment | object | yes | The source's leads by enrichment outcome. The four buckets partition `total`, so the gap between captured and enriched leads is always attributable. THIS IS WHERE THE CREDITS GO: one credit per newly enriched person per source; repeat engagement rows are free, so spend continues after capture completes. A run's billing is settled and its downstream processing done when `pending` reaches 0 — the same moment `isFinal` becomes true, `leadsReady` flips, and the run's `lead.detected` webhooks and provider integrations fire. |
└total | integer | no | All leads held for this source, across runs. The same number as `progress.leadsTotal`. |
└pending | integer | no | Queued or in flight: work still owed, and still to be billed. Above 0 means this source is not finished, whatever the capture run says. |
└completed | integer | no | Enriched: carries firmographics. This is a row count, not billed credits; repeats of a person already charged for this source are free. For a source in raw mode (`enrichLeads: false`) it counts the leads captured raw and charged instead — done, never enriched, carrying no firmographics, and read with GET /api/v1/leads/raw. |
└failed | integer | no | Enrichment attempts exhausted. Terminal and never charged — these are reported rather than left holding the lifecycle open. |
└skipped | integer | no | Terminal with no data to add: the provider returned nothing for the person, or the team has no chargeable enriching plan. Never charged. |
progress | object | yes | |
└postsCollected | integer | no | Posts found on the source in this run. |
└engagementsCaptured | integer | no | Engagements (likes + comments) captured in this run. |
└leadsTotal | integer | no | All leads currently held for this source, across runs. |
└leadsEnriched | integer | no | Of those, how many carry enriched firmographics. Unchanged, and equal to `enrichment.completed`; `enrichment` accounts for the rest. For a source in raw mode (`enrichLeads: false`) it counts the leads captured raw and charged, which carry none. |
stoppedBy | "credit_cap" | "exhausted" | "capture_empty" | "provider_limit" | "untracked" | null | no | HOW THIS RUN ENDED — the SAME ending `capture.stoppedBy` names, so the two fields never disagree. `credit_cap` means the source's own per-sync credit limit (tracked_profiles.credit_cap, reported as `creditCapPerSync` on GET /api/v1/sources) was reached and the run stopped there — an ORDINARY ending, not a failure: `state` stays `completed`, `errorCode` is null, nothing broke. It is the ending `capture.stoppedBy` spells `credits`: both spellings were published first and callers branch on each, so neither is renamed. `exhausted`, `capture_empty` and `provider_limit` mean exactly what they mean on `capture.stoppedBy`. `untracked` means the source was untracked while the run was in flight and the sweep was abandoned — an ending the capture vocabulary has no word for, so `capture.stoppedBy` is `null` beside it. `null` means there is no ending to name: no run has finished yet, the run FAILED (read `state` and `errorCode`), or this is a KEYWORD sweep, which records how it ended on keyword_searches.last_run_stopped_by and GET /api/v1/sources reports as `lastRun.stoppedBy`. ⚠ CHANGED IN THIS RELEASE: this field used to be `null` for every ordinary run — beside `capture.stoppedBy: "exhausted"`, and beside a post the provider had cut off at a third of its reactions — so a caller reading only this field saw no ending at all. ⚠️ THOSE TWO FIELDS ARE NOT THE SAME LIST: `lastRun.stoppedBy` answers "how did this keyword sweep end" (budget / credits / exhausted / error / ai_error / team_cap / lead_cap); this one answers how a person, company-page or tracked-post CAPTURE ended. Read from the same job row the rest of this response describes, so it is about the CURRENT run and not about the source's history. PER SYNC, EVERY SYNC: it bounds EACH sync of that one person, company page or post — the capture cap counts lead rows, while billing charges once per new person per source — not the first pull only and not the life of the source, and there is NO ESTIMATE, NO CONFIRMATION GATE AND NO TEAM CEILING behind it: nothing prices a sync before you set the cap, raising or lowering it never needs `confirmSpend`, and the team's `dailyCeiling` does not count a credit of it. ⚠️ A KEYWORD SEARCH'S `creditCap` IS THE OTHER FIELD AND HAS ALL THREE: it is the daily bound on a RECURRING SWEEP, POST /api/v1/keyword/estimate prices it as `estimatedDailyMax`, `confirmSpend` gates a create or a raise with a `409 spend_confirmation_required`, and the team's `dailyCeiling` stops it — and `dailyCeiling` COUNTS KEYWORD SPEND ONLY, so no number of profile syncs can ever reach it. |
lastSyncedAt | string <date-time> | no | |
nextSyncAt | string <date-time> | no | When the next scheduled sync is due (daily cadence). null while a sync is currently running — there is no next run scheduled until the active one finishes; use `state`/`isFinal` to see it is in progress. null for a source that is not ACTIVE — untracked, or paused after repeated not-found errors — always: nothing will run it (untracking parks its schedule, and a stored value is never served for a source that is not active). |
leadsReady | boolean | no | True once leads are ready to read. |
error | string | no | Human-readable failure reason when `state` is `failed`. Owned Cornersight text - it never contains the upstream data provider's raw response. Wording may change; branch on `errorCode`. |
errorCode | "insufficient_enriching_credits" | "not_found" | "page_out_of_range" | "invalid_request" | "provider_rate_limited" | "provider_access_denied" | "provider_unavailable" | "internal_error" | "ai_error" | "search_term_rejected" | null | no | Stable, machine-readable reason for the failure, or `null` when the sync has not failed. The values JobStatus.errorCode uses, plus two that only a KEYWORD sweep produces and that are the CUSTOMER'S to fix: `ai_error` — the search's AI filter failed on the team's own AI setup (a model the provider does not recognise, a revoked key, an exhausted quota); `error` says which and what to change, in the same words as `lastRun.reason`, and `lastRun.aiErrorCode` carries the fine-grained code. `search_term_rejected` — the data provider refused one of the search's terms (HTTP 400); `error` names the term. The same term is refused on every run, so edit the search's keywords — retrying will not help. Before this release both were reported as `internal_error` ("retrying may succeed"), which told the customer the problem was Cornersight's. |
| Status | Meaning | Example error |
|---|---|---|
| 400 | The path segment is not a source id (a UUID from GET /api/v1/sources), or the body is invalid. A malformed id is deliberately a 400 rather than a 404: "that is not an id" and "you do not have that source" are different answers. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | This team cannot be used (subscription inactive or blocked). | |
| 404 | No source with that id on this team — including one you UNTRACKED. Untracking is a soft delete, and a deactivated person, company or post is not readable and not actionable: its retained leads are out of GET /api/v1/leads too, and its webhook config is what a push would deliver to. A STOPPED KEYWORD SEARCH is the deliberate exception — its leads are kept and served, so it stays readable and editable here. The 404 is identical for an id that belongs to another team, so this is not an existence oracle. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 500 | The sync state could not be read. |
Sync a tracked person, company page or post now, by source id
/api/v1/sources/{id}/syncQUEUE A CAPTURE SYNC OF A TRACKED PERSON, COMPANY PAGE OR TRACKED POST NOW, instead of waiting for its next scheduled sync. RE-TRACKING DOES NOT RE-SYNC: POST /api/v1/enrich/profile or /enrich/company with `saveTrackedProfile: true`, and POST /api/v1/post/track, on a source that has already synced apply the settings sent and queue nothing (`syncId: null` + `syncNotQueuedReason`). This is the sync. **Cost.** Charged like any sync of that source: one enriching credit per NEW person for the source, repeat engagement free, bounded by the source's `creditCapPerSync` when it has one. There is no separate price; a second sync in a day costs what the next day's scheduled sync would. **Never stacked.** When a sync of the source is already queued, running or paused for enriching credits, nothing new is queued: the answer is `200` with `queued: false`, that job's `syncId` and `status`, and a `message`. Otherwise it is `202` with `queued: true` and the new job's `syncId`. Follow either with GET /api/v1/sources/{id}/sync. **Schedule.** The source's next scheduled sync moves to at least 24 hours from now, so the daily sync does not run it again straight after; a `nextSyncAt` already further out is left as it is. **Keyword searches are refused** with `400` and code `keyword_search_not_syncable`: a keyword search runs on its own daily schedule and each run may spend its `creditCap`, so an extra run would spend it twice in one day. There is no run-now route for a keyword search; follow its runs with GET /api/v1/keyword/{id}/sync, change it with PATCH /api/v1/keyword/{id}, and restart a stopped one with POST /api/v1/keyword/track.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | string <uuid> | yes | The source id from GET /api/v1/sources, of a person, a company page or a tracked post. |
Responses
200 — A sync of this source was already queued, running or paused for credits; nothing new was queued.
| Field | Type | Required | Description |
|---|---|---|---|
sourceId | string <uuid> | yes | |
type | "person" | "company" | "post" | yes | |
username | string | yes | The row's stored label: a handle for a person or company page, the activity URN for a post. |
queued | false | yes | |
syncId | string | yes | The job already in flight. GET /api/v1/sources/{id}/sync reports it. |
status | "pending" | "running" | "paused" | yes | |
message | string | yes |
202 — A sync was queued. It runs within seconds to minutes; follow it with GET /api/v1/sources/{id}/sync.
| Field | Type | Required | Description |
|---|---|---|---|
sourceId | string <uuid> | yes | |
type | "person" | "company" | "post" | yes | |
username | string | yes | |
queued | true | yes | |
syncId | string | yes | |
status | "pending" | yes |
| Status | Meaning | Example error |
|---|---|---|
| 400 | The path segment is not a source id (a UUID from GET /api/v1/sources), or the source is a keyword search (`keyword_search_not_syncable`). | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | This team cannot be used (subscription inactive or blocked). | |
| 404 | No source with that id on this team — including one you untracked, which is not actionable. The 404 is identical for an id that belongs to another team. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 500 | The sync could not be queued. | |
| 502 | The source's sync jobs could not be read. |
Get webhook configuration for any tracked source, by source id
/api/v1/sources/{id}/webhookRead the webhook configuration — delivery URL and flags — of any tracked source. ADDRESSED BY SOURCE ID, AND IT WORKS FOR EVERY KIND OF SOURCE — a person, a company page, a TRACKED POST or a KEYWORD SEARCH. The per-kind routes are keyed by whatever identifier that kind happens to have (/api/v1/{profile|company}/{username}/... by LinkedIn handle, /api/v1/keyword/{id}/... by source id), which is why a tracked post — whose identifier is an activity URN — had no route at all. It was never the SETTING that was missing: a tracked post is a tracked_profiles row like any other, its webhook fires through the same path, its leads are scored against the same ICP rules, and POST /api/v1/post/track has always returned a `syncId` from a real sync job. Only the addressing was. Same column, same delivery path and same four flags for every kind: a tracked post's and a keyword search's `lead.detected` have always fired, and only reading and setting them from outside the dashboard is new. FOR A TRACKED POST, `webhookUrl`, `icpOnly` and `autoSend` apply to its `lead.detected` exactly as for any source (`autoSend: false` holds its leads for POST /api/v1/sources/{id}/push); `syncEvents` does not, because no sync lifecycle event is ever sent for a post, so the PUT refuses `syncEvents: true` on one.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | string <uuid> | yes | The SOURCE id, as GET /api/v1/sources returns it in each source's `id`. Every kind of source has one — that is the whole point of this family: a person and a company page are also reachable by handle, a keyword search's handle is its free-text search terms, and a tracked post's is an activity URN, so the id is the only identifier all four share. |
Responses
200 — The source's current webhook configuration.
| Field | Type | Required | Description |
|---|---|---|---|
sourceId | string <uuid> | yes | The source id you addressed — the same value GET /api/v1/sources reports as `id`. |
type | "person" | "company" | "post" | "keyword" | yes | The kind of source this id turned out to be, spelled exactly as GET /api/v1/sources spells it. You do not have to know the kind to call these routes; this is how you learn it. |
username | string | yes | The row's stored label, which means something different per kind: a LinkedIn handle for a person or company page, the activity URN for a tracked post, and the joined search terms for a keyword search. Reported, never used to address these routes. |
webhook | object | yes | |
└webhookUrl | string | no | Where leads are POSTed. Null = no webhook. |
└icpOnly | boolean | no | Only deliver leads matching the source's ICP filter. |
└autoSend | boolean | no | Auto-deliver new leads (true) or deliver only on explicit push (false). |
└syncEvents | boolean | no | Also send signed sync.completed/sync.failed lifecycle callbacks. |
| Status | Meaning | Example error |
|---|---|---|
| 400 | The path segment is not a source id (a UUID from GET /api/v1/sources), or the body is invalid. A malformed id is deliberately a 400 rather than a 404: "that is not an id" and "you do not have that source" are different answers. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 404 | No source with that id on this team — including one you UNTRACKED. Untracking is a soft delete, and a deactivated person, company or post is not readable and not actionable: its retained leads are out of GET /api/v1/leads too, and its webhook config is what a push would deliver to. A STOPPED KEYWORD SEARCH is the deliberate exception — its leads are kept and served, so it stays readable and editable here. The 404 is identical for an id that belongs to another team, so this is not an existence oracle. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
Configure the webhook for any tracked source, by source id
/api/v1/sources/{id}/webhookConfigure how leads are delivered OUT of Cornersight for ANY tracked source. ADDRESSED BY SOURCE ID, AND IT WORKS FOR EVERY KIND OF SOURCE — a person, a company page, a TRACKED POST or a KEYWORD SEARCH. The per-kind routes are keyed by whatever identifier that kind happens to have (/api/v1/{profile|company}/{username}/... by LinkedIn handle, /api/v1/keyword/{id}/... by source id), which is why a tracked post — whose identifier is an activity URN — had no route at all. It was never the SETTING that was missing: a tracked post is a tracked_profiles row like any other, its webhook fires through the same path, its leads are scored against the same ICP rules, and POST /api/v1/post/track has always returned a `syncId` from a real sync job. Only the addressing was. PARTIAL UPDATE: only the fields present in the body change, so a flag can be flipped without restating the URL; a body with none of them is a 400 rather than a silent no-op. EXPLICIT `false` IS A VALUE — `autoSend: false` puts the source into deliver-only-on-explicit-push mode, which is what POST /api/v1/{profile|company}/{username}/push, /api/v1/keyword/{id}/push and, for any kind (the only push a tracked post has), /api/v1/sources/{id}/push then act on. `webhookUrl` of `""` or `null` CLEARS delivery, and turns `syncEvents` off with it because there is nowhere left to deliver — asking for `syncEvents: true` with no URL is a 400 instead. The URL passes the same guard the delivery path applies, so a URL that saves is always one that can fire. FOR A TRACKED POST, `webhookUrl`, `icpOnly` and `autoSend` apply to its `lead.detected` exactly as for any source (`autoSend: false` holds its leads for POST /api/v1/sources/{id}/push), while `syncEvents: true` is a 400 with `code: "not_supported_for_post"`: no sync lifecycle event is ever sent for a post. SAVING A URL DOES NOT DELIVER HISTORY; the push endpoints do that. UNKNOWN FIELDS ARE REFUSED: a property not listed here is a 400 carrying `code: "unknown_field"` and naming the offending field, rather than a 200 that silently dropped it.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | string <uuid> | yes | The SOURCE id, as GET /api/v1/sources returns it in each source's `id`. Every kind of source has one — that is the whole point of this family: a person and a company page are also reachable by handle, a keyword search's handle is its free-text search terms, and a tracked post's is an activity URN, so the id is the only identifier all four share. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
webhookUrl | string | no | Public https URL to POST leads to. Empty string or null clears it (localhost/private hosts are rejected). |
icpOnly | boolean | no | Deliver only leads whose isIcp is true. Applies to every kind of source, a tracked post included. |
autoSend | boolean | no | Queue FUTURE leads automatically (default true). `false` = deliver only on an explicit push, which for any kind of source, a tracked post included, is POST /api/v1/sources/{id}/push. |
syncEvents | boolean | no | Send sync.completed / sync.failed. Requires a webhookUrl to deliver to. Not available for a TRACKED POST: no sync lifecycle event is ever sent for a post, so `true` on one is a 400 with `code: "not_supported_for_post"`. |
Responses
200 — The updated webhook configuration, read back from the database.
| Field | Type | Required | Description |
|---|---|---|---|
sourceId | string <uuid> | yes | The source id you addressed — the same value GET /api/v1/sources reports as `id`. |
type | "person" | "company" | "post" | "keyword" | yes | The kind of source this id turned out to be, spelled exactly as GET /api/v1/sources spells it. You do not have to know the kind to call these routes; this is how you learn it. |
username | string | yes | The row's stored label, which means something different per kind: a LinkedIn handle for a person or company page, the activity URN for a tracked post, and the joined search terms for a keyword search. Reported, never used to address these routes. |
webhook | object | yes | |
└webhookUrl | string | no | Where leads are POSTed. Null = no webhook. |
└icpOnly | boolean | no | Only deliver leads matching the source's ICP filter. |
└autoSend | boolean | no | Auto-deliver new leads (true) or deliver only on explicit push (false). |
└syncEvents | boolean | no | Also send signed sync.completed/sync.failed lifecycle callbacks. |
| Status | Meaning | Example error |
|---|---|---|
| 400 | The path segment is not a source id (a UUID from GET /api/v1/sources), or the body is invalid — including `syncEvents: true` on a TRACKED POST, `code: "not_supported_for_post"`. A malformed id is deliberately a 400 rather than a 404: "that is not an id" and "you do not have that source" are different answers. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 404 | No source with that id on this team — including one you UNTRACKED. Untracking is a soft delete, and a deactivated person, company or post is not readable and not actionable: its retained leads are out of GET /api/v1/leads too, and its webhook config is what a push would deliver to. A STOPPED KEYWORD SEARCH is the deliberate exception — its leads are kept and served, so it stays readable and editable here. The 404 is identical for an id that belongs to another team, so this is not an existence oracle. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
Get ICP criteria for any tracked source, by source id
/api/v1/sources/{id}/icpRead the ICP (Ideal Customer Profile) criteria — filter rules and match mode — that decide which of this source's leads are `isIcp`, and with a webhook's `icpOnly`, which are delivered. ADDRESSED BY SOURCE ID, AND IT WORKS FOR EVERY KIND OF SOURCE — a person, a company page, a TRACKED POST or a KEYWORD SEARCH. The per-kind routes are keyed by whatever identifier that kind happens to have (/api/v1/{profile|company}/{username}/... by LinkedIn handle, /api/v1/keyword/{id}/... by source id), which is why a tracked post — whose identifier is an activity URN — had no route at all. It was never the SETTING that was missing: a tracked post is a tracked_profiles row like any other, its webhook fires through the same path, its leads are scored against the same ICP rules, and POST /api/v1/post/track has always returned a `syncId` from a real sync job. Only the addressing was. The scoring has always been kind-blind: capture evaluates these rules off the source row without regard for what kind of source it is. `rules: []` means no filter at all, which is not the same as a filter that matches nothing.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | string <uuid> | yes | The SOURCE id, as GET /api/v1/sources returns it in each source's `id`. Every kind of source has one — that is the whole point of this family: a person and a company page are also reachable by handle, a keyword search's handle is its free-text search terms, and a tracked post's is an activity URN, so the id is the only identifier all four share. |
Responses
200 — The source's current ICP configuration.
| Field | Type | Required | Description |
|---|---|---|---|
sourceId | string <uuid> | yes | The source id you addressed — the same value GET /api/v1/sources reports as `id`. |
type | "person" | "company" | "post" | "keyword" | yes | The kind of source this id turned out to be, spelled exactly as GET /api/v1/sources spells it. You do not have to know the kind to call these routes; this is how you learn it. |
username | string | yes | The row's stored label, which means something different per kind: a LinkedIn handle for a person or company page, the activity URN for a tracked post, and the joined search terms for a keyword search. Reported, never used to address these routes. |
icp | object | yes | |
└matchMode | "all" | "any" | yes | |
└rules | object[] | yes | |
└column | string | yes | |
└operator | string | yes | |
└value | string | yes |
| Status | Meaning | Example error |
|---|---|---|
| 400 | The path segment is not a source id (a UUID from GET /api/v1/sources), or the body is invalid. A malformed id is deliberately a 400 rather than a 404: "that is not an id" and "you do not have that source" are different answers. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 404 | No source with that id on this team — including one you UNTRACKED. Untracking is a soft delete, and a deactivated person, company or post is not readable and not actionable: its retained leads are out of GET /api/v1/leads too, and its webhook config is what a push would deliver to. A STOPPED KEYWORD SEARCH is the deliberate exception — its leads are kept and served, so it stays readable and editable here. The 404 is identical for an id that belongs to another team, so this is not an existence oracle. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
Set ICP criteria for any tracked source, by source id
/api/v1/sources/{id}/icpSet the ICP criteria for any tracked source (rules and/or match mode). ADDRESSED BY SOURCE ID, AND IT WORKS FOR EVERY KIND OF SOURCE — a person, a company page, a TRACKED POST or a KEYWORD SEARCH. The per-kind routes are keyed by whatever identifier that kind happens to have (/api/v1/{profile|company}/{username}/... by LinkedIn handle, /api/v1/keyword/{id}/... by source id), which is why a tracked post — whose identifier is an activity URN — had no route at all. It was never the SETTING that was missing: a tracked post is a tracked_profiles row like any other, its webhook fires through the same path, its leads are scored against the same ICP rules, and POST /api/v1/post/track has always returned a `syncId` from a real sync job. Only the addressing was. Partial update: pass `rules` and/or `matchMode`; a body with neither is a 400. `rules: []` or `null` clears the filter, which makes every lead ICP again — a widening, not a narrowing. EXISTING LEADS ARE RE-SCORED IMMEDIATELY, the same retroactive pass the dashboard runs, so `isIcp` filters and `icpOnly` delivery reflect the change now rather than at the next sync. `dryRun: true` ANSWERS “WHAT WOULD THIS SELECT?” WITHOUT APPLYING IT: validate, evaluate against this source's existing leads, and answer 200 with `counts` — `{ matching, notMatching }` over every lead the source has captured — a `sample` of up to ten matching leads by `id` and `name`, the `icp` (`matchMode` and `rules`) that was evaluated, and `dryRun: true`. Nothing is written and nothing fires. Use it before any rule change on a source whose webhook delivers `icpOnly` or whose pushes use `scope: "icp"`, because saving re-scores every existing lead immediately. UNKNOWN FIELDS ARE REFUSED: a property not listed here is a 400 carrying `code: "unknown_field"` and naming the offending field, rather than a 200 that silently dropped it.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | string <uuid> | yes | The SOURCE id, as GET /api/v1/sources returns it in each source's `id`. Every kind of source has one — that is the whole point of this family: a person and a company page are also reachable by handle, a keyword search's handle is its free-text search terms, and a tracked post's is an activity URN, so the id is the only identifier all four share. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
dryRun | boolean | no | Evaluate this rule set against the source's ALREADY-CAPTURED leads and CHANGE NOTHING: no rules are written, no lead is re-scored, and no webhook or push fires. Same meaning as `dryRun` on the push endpoints. The response carries `dryRun: true` plus `counts` and a `sample`, so a caller can assert on that flag before trusting that a live source was left alone. THE CONFIG EVALUATED IS THE EFFECTIVE ONE — the fields in this body over the ones already stored, which is what saving would leave behind — so `{"matchMode":"any","dryRun":true}` scores the STORED rules under the new mode, and `{"dryRun":true}` alone evaluates the configuration as it stands. Validation runs first, so a rule set this endpoint would refuse is refused here too rather than previewed.default: false |
matchMode | "all" | "any" | no | |
rules | object[] | no | The ICP filter rules. [] or null clears the filter. |
└column | string | yes | |
└operator | string | yes | |
└value | string | yes |
Responses
200 — The updated ICP configuration, with existing leads already re-scored.
| Field | Type | Required | Description |
|---|---|---|---|
dryRun | true | no | PRESENT, AND ALWAYS `true`, ONLY ON A DRY RUN — absent on a PUT that actually wrote. Its presence is the assertion that nothing was changed and nothing fired; `icp` then reports the configuration that WOULD have been saved rather than the one that was. |
counts | object | no | Dry runs only. How this source's already-captured leads split under the evaluated configuration. The two add up to every lead the source has captured, and they are produced by the SAME pass that re-scores `isIcp` on a real save — so the preview cannot disagree with what saving does. |
└matching | integer | yes | Leads that WOULD be marked `isIcp: true`. |
└notMatching | integer | yes | Leads that would NOT — the cohort an `icpOnly` webhook would stop delivering and a `scope: "icp"` push would stop selecting. |
sample | object[] | no | Dry runs only. Up to ten of the matching leads, so the counts can be checked against real people. A SAMPLE, not a page: no cursor and no ordering guarantee — use GET /api/v1/leads?isIcp=true after saving for the full set. |
└id | string <uuid> | yes | The lead id, exactly as GET /api/v1/leads reports it. |
└name | string | yes | The lead's name, or null when enrichment has not resolved one. |
sourceId | string <uuid> | yes | The source id you addressed — the same value GET /api/v1/sources reports as `id`. |
type | "person" | "company" | "post" | "keyword" | yes | The kind of source this id turned out to be, spelled exactly as GET /api/v1/sources spells it. You do not have to know the kind to call these routes; this is how you learn it. |
username | string | yes | The row's stored label, which means something different per kind: a LinkedIn handle for a person or company page, the activity URN for a tracked post, and the joined search terms for a keyword search. Reported, never used to address these routes. |
icp | object | yes | |
└matchMode | "all" | "any" | yes | |
└rules | object[] | yes | |
└column | string | yes | |
└operator | string | yes | |
└value | string | yes |
| Status | Meaning | Example error |
|---|---|---|
| 400 | The path segment is not a source id (a UUID from GET /api/v1/sources), or the body is invalid. A malformed id is deliberately a 400 rather than a 404: "that is not an id" and "you do not have that source" are different answers. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 404 | No source with that id on this team — including one you UNTRACKED. Untracking is a soft delete, and a deactivated person, company or post is not readable and not actionable: its retained leads are out of GET /api/v1/leads too, and its webhook config is what a push would deliver to. A STOPPED KEYWORD SEARCH is the deliberate exception — its leads are kept and served, so it stays readable and editable here. The 404 is identical for an id that belongs to another team, so this is not an existence oracle. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
Get webhook config for a tracked personal LinkedIn profile
/api/v1/profile/{username}/webhookRead the webhook configuration (delivery URL and flags) for a tracked personal LinkedIn profile.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | Public identifier of the tracked personal profile (not a full URL), e.g. demo-profile. |
Responses
200 — Current webhook configuration for the tracked personal LinkedIn profile.
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | |
profileType | "person" | "company" | yes | |
webhook | object | yes | |
└webhookUrl | string | no | Where leads are POSTed. Null = no webhook. |
└icpOnly | boolean | no | Only deliver leads matching the source's ICP filter. |
└autoSend | boolean | no | Auto-deliver new leads (true) or deliver only on explicit push (false). |
└syncEvents | boolean | no | Also send signed sync.completed/sync.failed lifecycle callbacks. |
| Status | Meaning | Example error |
|---|---|---|
| 400 | The `username` field is required. | username is required |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The delete is forbidden — either the team's subscription is inactive / its trial has expired, or the profile is a trial profile (trial profiles cannot be deleted; subscribe to a paid plan to manage profiles). | Team subscription not active |
| 404 | The personal LinkedIn profile is not tracked by the authenticated team, or it has been untracked. | Profile not tracked |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
Configure the webhook for a tracked personal LinkedIn profile
/api/v1/profile/{username}/webhookConfigure how leads are delivered OUT of Cornersight for a tracked personal LinkedIn profile — previously a dashboard-only capability. Partial update: only fields present in the body change. webhookUrl must be a public https URL; an empty string or null clears it. syncEvents requires a webhookUrl. UNKNOWN FIELDS ARE REFUSED: a property not listed here is a 400 carrying `code: "unknown_field"` and naming the offending field, rather than a 200 that silently dropped it.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | Public identifier of the tracked personal profile (not a full URL), e.g. demo-profile. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
webhookUrl | string | no | Public https URL to POST leads to. Empty string or null clears it (localhost/private hosts are rejected). |
icpOnly | boolean | no | |
autoSend | boolean | no | |
syncEvents | boolean | no | Requires a webhookUrl to deliver to. |
Responses
200 — Updated webhook configuration.
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | |
profileType | "person" | "company" | yes | |
webhook | object | yes | |
└webhookUrl | string | no | Where leads are POSTed. Null = no webhook. |
└icpOnly | boolean | no | Only deliver leads matching the source's ICP filter. |
└autoSend | boolean | no | Auto-deliver new leads (true) or deliver only on explicit push (false). |
└syncEvents | boolean | no | Also send signed sync.completed/sync.failed lifecycle callbacks. |
| Status | Meaning | Example error |
|---|---|---|
| 400 | The `username` field is required. | username is required |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The delete is forbidden — either the team's subscription is inactive / its trial has expired, or the profile is a trial profile (trial profiles cannot be deleted; subscribe to a paid plan to manage profiles). | Team subscription not active |
| 404 | The personal LinkedIn profile is not tracked by the authenticated team, or it has been untracked. | Profile not tracked |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
Get webhook config for a tracked LinkedIn company page
/api/v1/company/{username}/webhookRead the webhook configuration (delivery URL and flags) for a tracked LinkedIn company page.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | Public identifier of the tracked personal profile (not a full URL), e.g. demo-profile. |
Responses
200 — Current webhook configuration for the tracked LinkedIn company page.
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | |
profileType | "person" | "company" | yes | |
webhook | object | yes | |
└webhookUrl | string | no | Where leads are POSTed. Null = no webhook. |
└icpOnly | boolean | no | Only deliver leads matching the source's ICP filter. |
└autoSend | boolean | no | Auto-deliver new leads (true) or deliver only on explicit push (false). |
└syncEvents | boolean | no | Also send signed sync.completed/sync.failed lifecycle callbacks. |
| Status | Meaning | Example error |
|---|---|---|
| 400 | The `username` field is required. | username is required |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The delete is forbidden — either the team's subscription is inactive / its trial has expired, or the profile is a trial profile (trial profiles cannot be deleted; subscribe to a paid plan to manage profiles). | Team subscription not active |
| 404 | The personal LinkedIn profile is not tracked by the authenticated team, or it has been untracked. | Profile not tracked |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
Configure the webhook for a tracked LinkedIn company page
/api/v1/company/{username}/webhookConfigure how leads are delivered OUT of Cornersight for a tracked LinkedIn company page — previously a dashboard-only capability. Partial update: only fields present in the body change. webhookUrl must be a public https URL; an empty string or null clears it. syncEvents requires a webhookUrl. UNKNOWN FIELDS ARE REFUSED: a property not listed here is a 400 carrying `code: "unknown_field"` and naming the offending field, rather than a 200 that silently dropped it.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | Public identifier of the tracked personal profile (not a full URL), e.g. demo-profile. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
webhookUrl | string | no | Public https URL to POST leads to. Empty string or null clears it (localhost/private hosts are rejected). |
icpOnly | boolean | no | |
autoSend | boolean | no | |
syncEvents | boolean | no | Requires a webhookUrl to deliver to. |
Responses
200 — Updated webhook configuration.
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | |
profileType | "person" | "company" | yes | |
webhook | object | yes | |
└webhookUrl | string | no | Where leads are POSTed. Null = no webhook. |
└icpOnly | boolean | no | Only deliver leads matching the source's ICP filter. |
└autoSend | boolean | no | Auto-deliver new leads (true) or deliver only on explicit push (false). |
└syncEvents | boolean | no | Also send signed sync.completed/sync.failed lifecycle callbacks. |
| Status | Meaning | Example error |
|---|---|---|
| 400 | The `username` field is required. | username is required |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The delete is forbidden — either the team's subscription is inactive / its trial has expired, or the profile is a trial profile (trial profiles cannot be deleted; subscribe to a paid plan to manage profiles). | Team subscription not active |
| 404 | The personal LinkedIn profile is not tracked by the authenticated team, or it has been untracked. | Profile not tracked |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
Get webhook config for a tracked keyword search
/api/v1/keyword/{id}/webhookRead the webhook configuration (delivery URL and flags) for a tracked KEYWORD SEARCH. Addressed by SOURCE ID, not by keyword text: a keyword search's identifier in GET /api/v1/sources is `id`, while its `username` is the free-text keywords it searches for. Same webhook and same delivery as every other source kind - a keyword search's `lead.detected` has always fired; only reading and setting it was dashboard-only. Its ICP config and sync status are reachable the same way, at /api/v1/keyword/{id}/icp and /api/v1/keyword/{id}/sync.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | string <uuid> | yes | The keyword search's source id, from GET /api/v1/sources. A keyword search is addressed by id, not by its keyword text. |
Responses
200 — Current webhook configuration for the tracked keyword search.
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | |
profileType | "keyword" | yes | |
webhook | object | yes | |
└webhookUrl | string | no | Where leads are POSTed. Null = no webhook. |
└icpOnly | boolean | no | Only deliver leads matching the source's ICP filter. |
└autoSend | boolean | no | Auto-deliver new leads (true) or deliver only on explicit push (false). |
└syncEvents | boolean | no | Also send signed sync.completed/sync.failed lifecycle callbacks. |
| Status | Meaning | Example error |
|---|---|---|
| 400 | The path segment is not a source id, or the body is invalid. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The delete is forbidden — either the team's subscription is inactive / its trial has expired, or the profile is a trial profile (trial profiles cannot be deleted; subscribe to a paid plan to manage profiles). | Team subscription not active |
| 404 | No keyword search with that id for this team. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
Configure the webhook for a tracked keyword search
/api/v1/keyword/{id}/webhookConfigure how leads are delivered OUT of Cornersight for a tracked KEYWORD SEARCH - previously a dashboard-only capability. Addressed by SOURCE ID (from GET /api/v1/sources), not by keyword text. Partial update: only fields present in the body change. webhookUrl must be a public https URL; an empty string or null clears it. syncEvents requires a destination. Its ICP config and sync status are reachable the same way, at /api/v1/keyword/{id}/icp and /api/v1/keyword/{id}/sync. UNKNOWN FIELDS ARE REFUSED: a property not listed here is a 400 carrying `code: "unknown_field"` and naming the offending field, rather than a 200 that silently dropped it.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | string <uuid> | yes | The keyword search's source id, from GET /api/v1/sources. A keyword search is addressed by id, not by its keyword text. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
webhookUrl | string | no | Public https URL to POST leads to. Empty string or null clears it (localhost/private hosts are rejected). |
icpOnly | boolean | no | |
autoSend | boolean | no | |
syncEvents | boolean | no | Requires a webhookUrl to deliver to. |
Responses
200 — Updated webhook configuration.
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | |
profileType | "keyword" | yes | |
webhook | object | yes | |
└webhookUrl | string | no | Where leads are POSTed. Null = no webhook. |
└icpOnly | boolean | no | Only deliver leads matching the source's ICP filter. |
└autoSend | boolean | no | Auto-deliver new leads (true) or deliver only on explicit push (false). |
└syncEvents | boolean | no | Also send signed sync.completed/sync.failed lifecycle callbacks. |
| Status | Meaning | Example error |
|---|---|---|
| 400 | The path segment is not a source id, or the body is invalid. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The delete is forbidden — either the team's subscription is inactive / its trial has expired, or the profile is a trial profile (trial profiles cannot be deleted; subscribe to a paid plan to manage profiles). | Team subscription not active |
| 404 | No keyword search with that id for this team. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
Get ICP criteria for a tracked personal LinkedIn profile
/api/v1/profile/{username}/icpRead the ICP (Ideal Customer Profile) criteria — filter rules + match mode — that decide which of this source's leads are isIcp (and, with a webhook's icpOnly, which get delivered). Previously dashboard-only.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | Public identifier of the tracked personal profile (not a full URL), e.g. demo-profile. |
Responses
200 — Current ICP configuration for the tracked personal LinkedIn profile.
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | |
profileType | "person" | "company" | yes | |
icp | object | yes | |
└matchMode | "all" | "any" | yes | How rule groups combine: 'all' = AND (default), 'any' = OR. |
└rules | object[] | yes | ICP filter rules. Within a column rules OR together; matchMode decides across columns. [] means no ICP filter. |
└column | "name" | "jobTitle" | "company" | "country" | "engagementType" | "date" | yes | |
└operator | "contains" | "equals" | "not_contains" | "not_equals" | "date_from" | "date_to" | yes | |
└value | string | yes |
| Status | Meaning | Example error |
|---|---|---|
| 400 | The `username` field is required. | username is required |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 404 | The personal LinkedIn profile is not tracked by the authenticated team, or it has been untracked. | Profile not tracked |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
Set ICP criteria for a tracked personal LinkedIn profile
/api/v1/profile/{username}/icpSet the ICP (Ideal Customer Profile) criteria for a tracked personal LinkedIn profile — previously dashboard-only. Partial update: pass rules and/or matchMode. rules of [] or null clears the filter. Existing leads are re-scored immediately so isIcp and icpOnly delivery reflect the change. `dryRun: true` ANSWERS “WHAT WOULD THIS SELECT?” WITHOUT APPLYING IT: validate, evaluate against this source's existing leads, and answer 200 with `counts` — `{ matching, notMatching }` over every lead the source has captured — a `sample` of up to ten matching leads by `id` and `name`, the `icp` (`matchMode` and `rules`) that was evaluated, and `dryRun: true`. Nothing is written and nothing fires. Use it before any rule change on a source whose webhook delivers `icpOnly` or whose pushes use `scope: "icp"`, because saving re-scores every existing lead immediately. UNKNOWN FIELDS ARE REFUSED: a property not listed here is a 400 carrying `code: "unknown_field"` and naming the offending field, rather than a 200 that silently dropped it.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | Public identifier of the tracked personal profile (not a full URL), e.g. demo-profile. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
dryRun | boolean | no | Evaluate this rule set against the source's ALREADY-CAPTURED leads and CHANGE NOTHING: no rules are written, no lead is re-scored, and no webhook or push fires. Same meaning as `dryRun` on the push endpoints. The response carries `dryRun: true` plus `counts` and a `sample`, so a caller can assert on that flag before trusting that a live source was left alone. THE CONFIG EVALUATED IS THE EFFECTIVE ONE — the fields in this body over the ones already stored, which is what saving would leave behind — so `{"matchMode":"any","dryRun":true}` scores the STORED rules under the new mode, and `{"dryRun":true}` alone evaluates the configuration as it stands. Validation runs first, so a rule set this endpoint would refuse is refused here too rather than previewed.default: false |
matchMode | "all" | "any" | no | How rule groups combine: 'all' = AND (default), 'any' = OR. |
rules | object[] | no | The ICP filter rules. [] or null clears the filter. |
└column | "name" | "jobTitle" | "company" | "country" | "engagementType" | "date" | yes | |
└operator | "contains" | "equals" | "not_contains" | "not_equals" | "date_from" | "date_to" | yes | |
└value | string | yes |
Responses
200 — Updated ICP configuration.
| Field | Type | Required | Description |
|---|---|---|---|
dryRun | true | no | PRESENT, AND ALWAYS `true`, ONLY ON A DRY RUN — absent on a PUT that actually wrote. Its presence is the assertion that nothing was changed and nothing fired; `icp` then reports the configuration that WOULD have been saved rather than the one that was. |
counts | object | no | Dry runs only. How this source's already-captured leads split under the evaluated configuration. The two add up to every lead the source has captured, and they are produced by the SAME pass that re-scores `isIcp` on a real save — so the preview cannot disagree with what saving does. |
└matching | integer | yes | Leads that WOULD be marked `isIcp: true`. |
└notMatching | integer | yes | Leads that would NOT — the cohort an `icpOnly` webhook would stop delivering and a `scope: "icp"` push would stop selecting. |
sample | object[] | no | Dry runs only. Up to ten of the matching leads, so the counts can be checked against real people. A SAMPLE, not a page: no cursor and no ordering guarantee — use GET /api/v1/leads?isIcp=true after saving for the full set. |
└id | string <uuid> | yes | The lead id, exactly as GET /api/v1/leads reports it. |
└name | string | yes | The lead's name, or null when enrichment has not resolved one. |
username | string | yes | |
profileType | "person" | "company" | yes | |
icp | object | yes | |
└matchMode | "all" | "any" | yes | |
└rules | object[] | yes | |
└column | string | yes | |
└operator | string | yes | |
└value | string | yes |
| Status | Meaning | Example error |
|---|---|---|
| 400 | The `username` field is required. | username is required |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 404 | The personal LinkedIn profile is not tracked by the authenticated team, or it has been untracked. | Profile not tracked |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
Get ICP criteria for a tracked LinkedIn company page
/api/v1/company/{username}/icpRead the ICP (Ideal Customer Profile) criteria — filter rules + match mode — for a tracked LinkedIn company page.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | Public identifier of the tracked company page (not a full URL), e.g. demo-company. |
Responses
200 — Current ICP configuration for the tracked LinkedIn company page.
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | |
profileType | "person" | "company" | yes | |
icp | object | yes | |
└matchMode | "all" | "any" | yes | |
└rules | object[] | yes | |
└column | string | yes | |
└operator | string | yes | |
└value | string | yes |
| Status | Meaning | Example error |
|---|---|---|
| 400 | The `username` field is required. | username is required |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 404 | The personal LinkedIn profile is not tracked by the authenticated team, or it has been untracked. | Profile not tracked |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
Set ICP criteria for a tracked LinkedIn company page
/api/v1/company/{username}/icpSet the ICP criteria for a tracked LinkedIn company page. Partial update: pass rules and/or matchMode. rules of [] or null clears the filter. Existing leads are re-scored immediately. `dryRun: true` ANSWERS “WHAT WOULD THIS SELECT?” WITHOUT APPLYING IT: validate, evaluate against this source's existing leads, and answer 200 with `counts` — `{ matching, notMatching }` over every lead the source has captured — a `sample` of up to ten matching leads by `id` and `name`, the `icp` (`matchMode` and `rules`) that was evaluated, and `dryRun: true`. Nothing is written and nothing fires. Use it before any rule change on a source whose webhook delivers `icpOnly` or whose pushes use `scope: "icp"`, because saving re-scores every existing lead immediately. UNKNOWN FIELDS ARE REFUSED: a property not listed here is a 400 carrying `code: "unknown_field"` and naming the offending field, rather than a 200 that silently dropped it.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | Public identifier of the tracked company page (not a full URL), e.g. demo-company. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
dryRun | boolean | no | Evaluate this rule set against the source's ALREADY-CAPTURED leads and CHANGE NOTHING: no rules are written, no lead is re-scored, and no webhook or push fires. Same meaning as `dryRun` on the push endpoints. The response carries `dryRun: true` plus `counts` and a `sample`, so a caller can assert on that flag before trusting that a live source was left alone. THE CONFIG EVALUATED IS THE EFFECTIVE ONE — the fields in this body over the ones already stored, which is what saving would leave behind — so `{"matchMode":"any","dryRun":true}` scores the STORED rules under the new mode, and `{"dryRun":true}` alone evaluates the configuration as it stands. Validation runs first, so a rule set this endpoint would refuse is refused here too rather than previewed.default: false |
matchMode | "all" | "any" | no | |
rules | object[] | no | The ICP filter rules. [] or null clears the filter. |
└column | string | yes | |
└operator | string | yes | |
└value | string | yes |
Responses
200 — Updated ICP configuration.
| Field | Type | Required | Description |
|---|---|---|---|
dryRun | true | no | PRESENT, AND ALWAYS `true`, ONLY ON A DRY RUN — absent on a PUT that actually wrote. Its presence is the assertion that nothing was changed and nothing fired; `icp` then reports the configuration that WOULD have been saved rather than the one that was. |
counts | object | no | Dry runs only. How this source's already-captured leads split under the evaluated configuration. The two add up to every lead the source has captured, and they are produced by the SAME pass that re-scores `isIcp` on a real save — so the preview cannot disagree with what saving does. |
└matching | integer | yes | Leads that WOULD be marked `isIcp: true`. |
└notMatching | integer | yes | Leads that would NOT — the cohort an `icpOnly` webhook would stop delivering and a `scope: "icp"` push would stop selecting. |
sample | object[] | no | Dry runs only. Up to ten of the matching leads, so the counts can be checked against real people. A SAMPLE, not a page: no cursor and no ordering guarantee — use GET /api/v1/leads?isIcp=true after saving for the full set. |
└id | string <uuid> | yes | The lead id, exactly as GET /api/v1/leads reports it. |
└name | string | yes | The lead's name, or null when enrichment has not resolved one. |
username | string | yes | |
profileType | "person" | "company" | yes | |
icp | object | yes | |
└matchMode | "all" | "any" | yes | |
└rules | object[] | yes | |
└column | string | yes | |
└operator | string | yes | |
└value | string | yes |
| Status | Meaning | Example error |
|---|---|---|
| 400 | The `username` field is required. | username is required |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 404 | The personal LinkedIn profile is not tracked by the authenticated team, or it has been untracked. | Profile not tracked |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
Get ICP criteria for a tracked keyword search
/api/v1/keyword/{id}/icpRead the ICP criteria (filter rules and match mode) for a tracked KEYWORD SEARCH. Addressed by SOURCE ID, not by keyword text: a keyword search's identifier in GET /api/v1/sources is `id`, while its `username` there is the free-text terms it searches for. The rules are the same ones capture evaluates: evaluateIcp reads icp_filter_rules off the source row without regard for source kind, so a keyword search's leads have always been scored against them - only reading and setting them was dashboard-only.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | string <uuid> | yes | The keyword search's source id, from GET /api/v1/sources. A keyword search is addressed by id, not by its keyword text. |
Responses
200 — Current ICP configuration for the tracked keyword search.
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | |
profileType | "keyword" | yes | |
icp | object | yes | |
└matchMode | "all" | "any" | yes | |
└rules | object[] | yes | |
└column | string | yes | |
└operator | string | yes | |
└value | string | yes |
| Status | Meaning | Example error |
|---|---|---|
| 400 | The path segment is not a source id, or the body is invalid. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 404 | No keyword search with that id for this team. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
Set ICP criteria for a tracked keyword search
/api/v1/keyword/{id}/icpSet the ICP criteria for a tracked KEYWORD SEARCH (rules and/or match mode). Re-scores existing leads' isIcp. Addressed by SOURCE ID, not by keyword text: a keyword search's identifier in GET /api/v1/sources is `id`, while its `username` there is the free-text terms it searches for. Pass rules: [] to clear the filter. `dryRun: true` ANSWERS “WHAT WOULD THIS SELECT?” WITHOUT APPLYING IT: validate, evaluate against this source's existing leads, and answer 200 with `counts` — `{ matching, notMatching }` over every lead the source has captured — a `sample` of up to ten matching leads by `id` and `name`, the `icp` (`matchMode` and `rules`) that was evaluated, and `dryRun: true`. Nothing is written and nothing fires. Use it before any rule change on a source whose webhook delivers `icpOnly` or whose pushes use `scope: "icp"`, because saving re-scores every existing lead immediately. UNKNOWN FIELDS ARE REFUSED: a property not listed here is a 400 carrying `code: "unknown_field"` and naming the offending field, rather than a 200 that silently dropped it.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | string <uuid> | yes | The keyword search's source id, from GET /api/v1/sources. A keyword search is addressed by id, not by its keyword text. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
dryRun | boolean | no | Evaluate this rule set against the source's ALREADY-CAPTURED leads and CHANGE NOTHING: no rules are written, no lead is re-scored, and no webhook or push fires. Same meaning as `dryRun` on the push endpoints. The response carries `dryRun: true` plus `counts` and a `sample`, so a caller can assert on that flag before trusting that a live source was left alone. THE CONFIG EVALUATED IS THE EFFECTIVE ONE — the fields in this body over the ones already stored, which is what saving would leave behind — so `{"matchMode":"any","dryRun":true}` scores the STORED rules under the new mode, and `{"dryRun":true}` alone evaluates the configuration as it stands. Validation runs first, so a rule set this endpoint would refuse is refused here too rather than previewed.default: false |
matchMode | "all" | "any" | no | |
rules | object[] | no | The ICP filter rules. [] or null clears the filter. |
└column | string | yes | |
└operator | string | yes | |
└value | string | yes |
Responses
200 — Updated ICP configuration.
| Field | Type | Required | Description |
|---|---|---|---|
dryRun | true | no | PRESENT, AND ALWAYS `true`, ONLY ON A DRY RUN — absent on a PUT that actually wrote. Its presence is the assertion that nothing was changed and nothing fired; `icp` then reports the configuration that WOULD have been saved rather than the one that was. |
counts | object | no | Dry runs only. How this source's already-captured leads split under the evaluated configuration. The two add up to every lead the source has captured, and they are produced by the SAME pass that re-scores `isIcp` on a real save — so the preview cannot disagree with what saving does. |
└matching | integer | yes | Leads that WOULD be marked `isIcp: true`. |
└notMatching | integer | yes | Leads that would NOT — the cohort an `icpOnly` webhook would stop delivering and a `scope: "icp"` push would stop selecting. |
sample | object[] | no | Dry runs only. Up to ten of the matching leads, so the counts can be checked against real people. A SAMPLE, not a page: no cursor and no ordering guarantee — use GET /api/v1/leads?isIcp=true after saving for the full set. |
└id | string <uuid> | yes | The lead id, exactly as GET /api/v1/leads reports it. |
└name | string | yes | The lead's name, or null when enrichment has not resolved one. |
username | string | yes | |
profileType | "keyword" | yes | |
icp | object | yes | |
└matchMode | "all" | "any" | yes | |
└rules | object[] | yes | |
└column | string | yes | |
└operator | string | yes | |
└value | string | yes |
| Status | Meaning | Example error |
|---|---|---|
| 400 | The path segment is not a source id, or the body is invalid. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 404 | No keyword search with that id for this team. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
Resolve a public LinkedIn handle to its member id (free)
/api/v1/profile/{username}/urnResolve any public LinkedIn handle to the member id (`ACoAA…`) the keyword targeting filters take. WHY IT EXISTS: `fromPerson` and `mentionsPerson` accept a member id and never a handle, and nothing else in this API could produce one — POST /api/v1/enrich/profile answers 404 for anyone who is not already one of your leads, and with `saveTrackedProfile` it TRACKS them, which queues a full sync and charges. THIS ENRICHES NOBODY, TRACKS NOBODY AND CHARGES NOTHING: no lead row is written, so no enriching credit is consumed, and the response says so as `creditsCharged: 0`. The only cost is one upstream lookup, which is cached — in flight, for the life of the worker, and by the provider for 24h — so repeating a handle is free. The standard per-team rate limit is the only cap. The handle may be given bare (`jasonlemkin`) or as a profile URL. A `404` with `code: "not_found"` means the provider served no member id for that handle — private, renamed or deleted — and retrying will not change it; a provider outage is a `502` carrying its own code instead.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | Public identifier from a profile URL (not a full URL), e.g. demo-profile. |
Responses
200 — The member id behind the handle. Nothing was enriched, tracked or charged.
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | The normalised handle that was looked up. |
memberId | string | yes | The LinkedIn member id — the same value profile enrichment returns as `entityUrn`. Accepted as-is by `fromPerson` and `mentionsPerson`. |
urn | string | yes | The canonical wrapped form, which is how GET /api/v1/sources reports the filter whichever spelling you sent. Either field is legal input. |
creditsCharged | 0 | yes | Always 0. Stated in the body rather than only in the prose, because the whole objection to resolving an id through enrichment was its cost. |
{
"username": "jasonlemkin",
"memberId": "ACoAAA5eqCEBzmkNfjyOp9_MseBpRQ-P17SuIos",
"urn": "urn:li:person:ACoAAA5eqCEBzmkNfjyOp9_MseBpRQ-P17SuIos",
"creditsCharged": 0
}| Status | Meaning | Example error |
|---|---|---|
| 400 | The handle could not be read as a LinkedIn public identifier. | |
| 404 | No member id is available for that handle — the provider has no such profile, or it is private, renamed or deleted. `code: "not_found"`. Retrying will not change it: fix the handle. | |
| 502 | The upstream lookup failed. The body carries the same `code` job status uses for provider failures; the provider's own response is never included. |
Read a profile's posts and their text, without tracking it (1 credit per post)
/api/v1/profile/{username}/posts⚠ ONE POST IS ONE CREDIT, AND THIS ENDPOINT USED TO BE FREE. Verified 20 September: 60 posts across four pages for a team whose credits used did not move, with nothing in sources, usage or any dashboard page. Every page of a walk is a different request body and so a different upstream cache key, so unlike the URN lookup this cannot be absorbed by caching — it was an uncharged fan-out to a paid upstream bounded only by a rate limit. ⚠ ASK TWO THINGS BEFORE FETCHING ANYTHING: HOW MANY POSTS do you want (1-60, and one post is one credit), and is this a ONE-OFF READ or a DAILY WATCH? A one-off read is this endpoint with `posts` and `confirmSpend`. A daily watch is a posts-only tracked source — POST /api/v1/enrich/{profile,company} with `saveTrackedProfile: true`, `mode: "posts_only"` and `postsPerSync` — which costs up to that many credits EVERY DAY until it is untracked, appears on the profile row in the dashboard, delivers each new post as a `post.detected` webhook and is readable at GET /api/v1/sources/{id}/posts. ⚠ WITHOUT `confirmSpend` THE CALL IS REFUSED 409 `spend_confirmation_required` AND NOTHING IS FETCHED OR CHARGED. That refusal is the useful part: it carries `estimatedCredits` (equal to `posts`), `remainingBalance` and the sentence to read out. Say the cost, wait for a yes, then re-send the identical request with `confirmSpend: true` — the round trip IS the consent. ⚠ YOU ARE CHARGED FOR THE POSTS ACTUALLY RETURNED, which is why the refusal says "up to": a profile holding 12 posts asked for 60 costs 12, and one holding none costs nothing. `creditsCharged` in the body is what was actually billed. ⚠ A BALANCE BELOW THE REQUEST IS A 402 `insufficient_enriching_credits` NAMING BOTH NUMBERS, and nothing is fetched. A partial set is deliberately not offered: serving 9 posts of a confirmed 20 would be a different call from the one that was authorised. ⚠ THERE IS NO PAGING ANY MORE. `limit` and `paginationToken` are REFUSED with a 400 naming `posts`, because "fetch 20" and "fetch 15 then 5 more" must not be two prices for one answer — the 15-per-page walk still happens, internally. An integration written against the free surface is TOLD rather than silently charged. ⚠ THE CHARGE LANDS IN THE ORDINARY CREDIT LEDGER under a new `posts_read` kind keyed by handle, so GET /api/v1/credits/usage shows it in `bySource` and names the handle in `byHandle`. ⚠ IT IS STILL A READ AND IT PERSISTS NOTHING: no tracked source is created, no sync is queued, no post or lead row is written, and NO ENGAGERS ARE COLLECTED — that fan-out is where a sweep's cost and ALL of its leads come from, and it is explicitly not what this does. `captured: false` says so in the body: paying for a read does not make it a capture. It is NOT POST /api/v1/profile/posts (the TRACKED posts job), which answers 404 for an untracked handle because its job UPSERTS every post it walks against a tracked source — that is the whole reason this endpoint exists. ⚠ A RATE LIMIT STILL BOUNDS IT AND IS NOW THE SECONDARY GUARD RATHER THAN THE ONLY ONE: 10 CALLS A MINUTE per team, shared between this endpoint and its sibling, against the 120/min ordinary reads get. A 429 MEANS CALLED-TOO-FAST, NOT OUT-OF-CREDITS — that is the 402, which has its own code. AN EMPTY `posts` ARRAY IS A REAL ANSWER and costs nothing: it is deliberately not a 404, which would be indistinguishable from a mistyped handle. An upstream that would not answer at all is a 502 carrying its own code, and that charges nothing either.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | Public identifier from a profile URL (not a full URL), e.g. demo-profile. NEED NOT BE TRACKED — that is the point of this endpoint. |
posts | integer | yes | How many posts to fetch, 1-60, newest first. ONE POST IS ONE CREDIT, so this is also the most this call can cost. REQUIRED, with NO DEFAULT: a default here would be a spend nobody asked for. You are charged for the posts actually returned, so a profile with fewer costs fewer. A value outside the range is a 400 naming it rather than a silent clamp. |
confirmSpend | boolean | no | Authorises the charge. Without it the call is refused 409 `spend_confirmation_required` and NOTHING is fetched or charged; that refusal carries `estimatedCredits` and `remainingBalance`. Say the cost to the person, wait for a yes, then re-send the identical request with `confirmSpend: true`. Do not send it on the first attempt to save a round trip — the round trip IS the consent. Anything other than `true` or `false` is a 400, never a silent yes. |
Responses
200 — The posts, and what they cost. One credit per post returned.
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | The normalised handle that was read. |
posts | object[] | yes | The page, newest first. EMPTY IS A REAL ANSWER — that profile has no posts we can read. |
└urn | string | yes | The post's URN — its identity, and what POST /api/v1/post/track takes if you later decide to capture this one post's engagers. Usually `urn:li:activity:…`; a post LinkedIn keys by its ugcPost or share id (typically a video or document post) keeps that form, `urn:li:ugcPost:…` or `urn:li:share:…`, and is never relabelled as an activity id, which would name a different post. |
└url | string | yes | Permalink to the post. |
└text | string | yes | The post's text — the field this endpoint exists to return. NULL, never missing, when the provider served a post without any, which is normal for an image or video post. Read `null`; do not test for the key. |
└postedAt | string <date-time> | yes | When it was posted, ISO 8601. A LinkedIn post id (activity, ugcPost or share) encodes its own creation time, and a person's post is dated by that id wherever it decodes, else by the provider's timestamp; a company post by the provider's timestamp, else its id. Null when neither gives one. |
└postedAtTimestamp | integer | yes | The same instant in epoch milliseconds, for callers that would rather not parse. Null when the provider gave no timestamp. |
└totalReactionCount | integer | yes | Reactions on the post AS THE PROVIDER REPORTED THEM at read time — a count, not the people. The reactors themselves are engagers and are NOT collected here. Null when the payload carried no count. |
└commentsCount | integer | yes | Comments on the post as reported at read time — again a count and not the commenters. Null when the payload carried no count. |
└contentType | "VIDEO" | "IMAGE" | "JOB" | "LIVE_VIDEO" | "DOCUMENT" | "COLLABORATIVE_ARTICLE" | null | yes | Post kind when the provider supplies an explicit matching type or unambiguous media. Same vocabulary as the keyword search contentType filter. Null, never omitted, when no matching type is available; plain text has no value in this vocabulary. |
creditsCharged | integer | yes | What this call was ACTUALLY billed — one credit per post returned, so it equals `posts.length` and may be lower than the `posts` you asked for. 0 when the walk came back empty. |
captured | false | yes | Always false. Paying for a read does not make it a capture: no tracked source, no sync, no post row, no lead, no engagers. If you want new posts every day, track the source with `mode: "posts_only"`. |
maxDepth | integer | yes | How far into a history this read goes, in posts — the same number `posts` is capped at. Published so a caller can tell "this is everything we serve" from "this profile has nothing more". |
{
"username": "jasonlemkin",
"posts": [
{
"urn": "urn:li:activity:7496817330904121344",
"url": "https://www.linkedin.com/feed/update/urn:li:activity:7496817330904121344/",
"text": "Most SaaS founders underprice for far too long. Here is the math.",
"postedAt": "2026-09-14T08:31:00.000Z",
"postedAtTimestamp": 1789000260000,
"totalReactionCount": 412,
"commentsCount": 57
}
],
"nextPaginationToken": "eyJ2IjoicHIxIiwicyI6MTV9",
"creditsCharged": 0,
"captured": false,
"maxDepth": 60
}| Status | Meaning | Example error |
|---|---|---|
| 400 | The handle could not be read as a LinkedIn public identifier, `limit` was out of range, or the `paginationToken` was not ours or was at/past `maxDepth`. The message distinguishes them. | |
| 402 | The team cannot fund the request. The message names BOTH numbers — what the read needs and what is left — and NOTHING was fetched or charged: the read is refused whole rather than served short, because a partial answer is not the call that was confirmed. `code` is `insufficient_enriching_credits`. | |
| 409 | NOT CONFIRMED. Nothing was fetched and nothing was charged. The body carries `estimatedCredits` (equal to `posts`) and `remainingBalance` and the sentence to read to whoever is paying; re-send the identical request with `confirmSpend: true` to proceed. `code` is `spend_confirmation_required` — the same code POST /api/v1/keyword/track answers with. | |
| 429 | This endpoint's own per-team rate budget is exhausted — tighter than the ordinary read limit, because every page costs an upstream call while charging nothing. NOT an out-of-credits condition. Retry after the window `Retry-After` names. | |
| 502 | The upstream would not answer. Carries the classified code, the same one job status uses. Worth retrying, unlike a 400. |
Read a company page's posts and their text, without tracking it (1 credit per post)
/api/v1/company/{username}/posts⚠ ONE POST IS ONE CREDIT, AND THIS ENDPOINT USED TO BE FREE. Verified 20 September: 60 posts across four pages for a team whose credits used did not move, with nothing in sources, usage or any dashboard page. Every page of a walk is a different request body and so a different upstream cache key, so unlike the URN lookup this cannot be absorbed by caching — it was an uncharged fan-out to a paid upstream bounded only by a rate limit. ⚠ ASK TWO THINGS BEFORE FETCHING ANYTHING: HOW MANY POSTS do you want (1-60, and one post is one credit), and is this a ONE-OFF READ or a DAILY WATCH? A one-off read is this endpoint with `posts` and `confirmSpend`. A daily watch is a posts-only tracked source — POST /api/v1/enrich/{profile,company} with `saveTrackedProfile: true`, `mode: "posts_only"` and `postsPerSync` — which costs up to that many credits EVERY DAY until it is untracked, appears on the profile row in the dashboard, delivers each new post as a `post.detected` webhook and is readable at GET /api/v1/sources/{id}/posts. ⚠ WITHOUT `confirmSpend` THE CALL IS REFUSED 409 `spend_confirmation_required` AND NOTHING IS FETCHED OR CHARGED. That refusal is the useful part: it carries `estimatedCredits` (equal to `posts`), `remainingBalance` and the sentence to read out. Say the cost, wait for a yes, then re-send the identical request with `confirmSpend: true` — the round trip IS the consent. ⚠ YOU ARE CHARGED FOR THE POSTS ACTUALLY RETURNED, which is why the refusal says "up to": a page holding 12 posts asked for 60 costs 12, and one holding none costs nothing. `creditsCharged` in the body is what was actually billed. ⚠ A BALANCE BELOW THE REQUEST IS A 402 `insufficient_enriching_credits` NAMING BOTH NUMBERS, and nothing is fetched. A partial set is deliberately not offered: serving 9 posts of a confirmed 20 would be a different call from the one that was authorised. ⚠ THERE IS NO PAGING ANY MORE. `limit` and `paginationToken` are REFUSED with a 400 naming `posts`, because "fetch 20" and "fetch 15 then 5 more" must not be two prices for one answer — the 15-per-page walk still happens, internally. An integration written against the free surface is TOLD rather than silently charged. ⚠ THE CHARGE LANDS IN THE ORDINARY CREDIT LEDGER under a new `posts_read` kind keyed by handle, so GET /api/v1/credits/usage shows it in `bySource` and names the handle in `byHandle`. ⚠ IT IS STILL A READ AND IT PERSISTS NOTHING: no tracked source is created, no sync is queued, no post or lead row is written, and NO ENGAGERS ARE COLLECTED — that fan-out is where a sweep's cost and ALL of its leads come from, and it is explicitly not what this does. `captured: false` says so in the body: paying for a read does not make it a capture. It is NOT POST /api/v1/company/posts (the TRACKED posts job), which answers 404 for an untracked handle because its job UPSERTS every post it walks against a tracked source — that is the whole reason this endpoint exists. ⚠ A RATE LIMIT STILL BOUNDS IT AND IS NOW THE SECONDARY GUARD RATHER THAN THE ONLY ONE: 10 CALLS A MINUTE per team, shared between this endpoint and its sibling, against the 120/min ordinary reads get. A 429 MEANS CALLED-TOO-FAST, NOT OUT-OF-CREDITS — that is the 402, which has its own code. AN EMPTY `posts` ARRAY IS A REAL ANSWER and costs nothing: it is deliberately not a 404, which would be indistinguishable from a mistyped handle. An upstream that would not answer at all is a 502 carrying its own code, and that charges nothing either.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | The COMPANY PAGE's slug from its URL, e.g. instantlyapp from linkedin.com/company/instantlyapp. A full company URL is accepted and unwrapped; a linkedin.com/in/… person URL is a 400 naming the profile read, never a company lookup. NEED NOT BE TRACKED — that is the point of this endpoint. |
posts | integer | yes | How many posts to fetch, 1-60, newest first. ONE POST IS ONE CREDIT, so this is also the most this call can cost. REQUIRED, with NO DEFAULT: a default here would be a spend nobody asked for. You are charged for the posts actually returned, so a profile with fewer costs fewer. A value outside the range is a 400 naming it rather than a silent clamp. |
confirmSpend | boolean | no | Authorises the charge. Without it the call is refused 409 `spend_confirmation_required` and NOTHING is fetched or charged; that refusal carries `estimatedCredits` and `remainingBalance`. Say the cost to the person, wait for a yes, then re-send the identical request with `confirmSpend: true`. Do not send it on the first attempt to save a round trip — the round trip IS the consent. Anything other than `true` or `false` is a 400, never a silent yes. |
Responses
200 — The posts, and what they cost. One credit per post returned.
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | The normalised company slug that was read — unwrapped, if you sent a full company URL. |
posts | object[] | yes | The page, newest first by postedAt. A company page's feed is NOT in date order (pinned and older posts surface between newer ones), so this read looks 25 posts past `posts`, sorts what it read by time and returns the newest — the look-ahead is never charged. EMPTY IS A REAL ANSWER — that page has no posts we can read. |
└urn | string | yes | The post's URN — its identity, and what POST /api/v1/post/track takes if you later decide to capture this one post's engagers. Usually `urn:li:activity:…`; a post LinkedIn keys by its ugcPost or share id (typically a video or document post) keeps that form, `urn:li:ugcPost:…` or `urn:li:share:…`, and is never relabelled as an activity id, which would name a different post. |
└url | string | yes | Permalink to the post. |
└text | string | yes | The post's text — the field this endpoint exists to return. NULL, never missing, when the provider served a post without any, which is normal for an image or video post. Read `null`; do not test for the key. |
└postedAt | string <date-time> | yes | When it was posted, ISO 8601. A LinkedIn post id (activity, ugcPost or share) encodes its own creation time, and a person's post is dated by that id wherever it decodes, else by the provider's timestamp; a company post by the provider's timestamp, else its id. Null when neither gives one. |
└postedAtTimestamp | integer | yes | The same instant in epoch milliseconds, for callers that would rather not parse. Null when the provider gave no timestamp. |
└totalReactionCount | integer | yes | Reactions on the post AS THE PROVIDER REPORTED THEM at read time — a count, not the people. The reactors themselves are engagers and are NOT collected here. Null when the payload carried no count. |
└commentsCount | integer | yes | Comments on the post as reported at read time — again a count and not the commenters. Null when the payload carried no count. |
└contentType | "VIDEO" | "IMAGE" | "JOB" | "LIVE_VIDEO" | "DOCUMENT" | "COLLABORATIVE_ARTICLE" | null | yes | Post kind when the provider supplies an explicit matching type or unambiguous media. Same vocabulary as the keyword search contentType filter. Null, never omitted, when no matching type is available; plain text has no value in this vocabulary. |
creditsCharged | integer | yes | What this call was ACTUALLY billed — one credit per post returned, so it equals `posts.length` and may be lower than the `posts` you asked for. 0 when the walk came back empty. |
captured | false | yes | Always false. Paying for a read does not make it a capture: no tracked source, no sync, no post row, no lead, no engagers. If you want new posts every day, track the source with `mode: "posts_only"`. |
maxDepth | integer | yes | How far into a history this read goes, in posts — the same number `posts` is capped at. Published so a caller can tell "this is everything we serve" from "this profile has nothing more". |
{
"username": "instantlyapp",
"posts": [
{
"urn": "urn:li:activity:7496817330904121344",
"url": "https://www.linkedin.com/feed/update/urn:li:activity:7496817330904121344/",
"text": "We shipped inbox placement tests today. Here is what we learned running 4,000 of them.",
"postedAt": "2026-09-14T08:31:00.000Z",
"postedAtTimestamp": 1789000260000,
"totalReactionCount": 412,
"commentsCount": 57
}
],
"nextPaginationToken": "eyJ2IjoicHIxIiwicyI6MTV9",
"creditsCharged": 0,
"captured": false,
"maxDepth": 60
}| Status | Meaning | Example error |
|---|---|---|
| 400 | The slug could not be read as a LinkedIn public identifier — a linkedin.com/in/… PERSON URL lands here and the message names GET /api/v1/profile/{username}/posts — or `limit` was out of range, or the `paginationToken` was not ours or was at/past `maxDepth`. The message distinguishes them. | |
| 402 | The team cannot fund the request. The message names BOTH numbers — what the read needs and what is left — and NOTHING was fetched or charged: the read is refused whole rather than served short, because a partial answer is not the call that was confirmed. `code` is `insufficient_enriching_credits`. | |
| 409 | NOT CONFIRMED. Nothing was fetched and nothing was charged. The body carries `estimatedCredits` (equal to `posts`) and `remainingBalance` and the sentence to read to whoever is paying; re-send the identical request with `confirmSpend: true` to proceed. `code` is `spend_confirmation_required` — the same code POST /api/v1/keyword/track answers with. | |
| 429 | The per-team rate budget these two posts reads SHARE is exhausted — tighter than the ordinary read limit, because every page costs an upstream call while charging nothing, and spent by GET /api/v1/profile/{username}/posts as well. NOT an out-of-credits condition. Retry after the window `Retry-After` names. | |
| 502 | The upstream would not answer. Carries the classified code, the same one job status uses. Worth retrying, unlike a 400. |
Read the posts a posts-only watch has seen
/api/v1/sources/{id}/postsWHAT A POSTS-ONLY TRACKED PROFILE OR KEYWORD SEARCH HAS SEEN, newest first by when WE saw it. A posts-only source delivers each new post as a `post.detected` webhook. ⚠ POSTS-ONLY SOURCES ONLY — 404 `not_posts_only` otherwise. An engagers source reports captured people as leads; an engagers-mode keyword search also reports post verdicts at GET /api/v1/sources/{id}/kept-posts, which does not include post text. ⚠ `firstSeenAt` IS WHEN WE SAW IT, NOT WHEN IT WAS POSTED. READING THIS CHARGES NOTHING: the credit was spent only when each new matching post was first recorded. EACH POST CARRIES `postedAt` AND `postedAtTimestamp`, and on a KEYWORD search also what the search said about it when it was found: `author` { `name`, `url`, `headline` }, `commentsCount`, `totalReactionCount` and `contentType`, in the names and types GET /api/v1/profile/{username}/posts uses. A keyword post's `postedAt` is the instant its activity URN encodes — the keyword search result has no time field — and keyword posts recorded before the September 2026 worker release that added them serve all of these as null (not back-filled). On a person or company watch `commentsCount`, `totalReactionCount` and `contentType` are what the watch saw when it FIRST RECORDED the post (never refreshed; null on watch posts recorded before the September 2026 worker release that added them, not back-filled), `postedAt` is the instant the post's activity URN encodes (or, for a post keyed by its ugcPost or share id, the instant that id encodes), and `author` is always null — the watched source is the author. Every key is present on every row; a null is never a zero.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | string <uuid> | yes | The tracked source's id, as GET /api/v1/sources returns it. |
limit | integer | no | Posts to return, 1-200. Defaults to 50. A value outside the range is a 400 naming it. |
Responses
200 — The posts this source has seen, newest first by `firstSeenAt`.
| Field | Type | Required | Description |
|---|---|---|---|
sourceId | string <uuid> | yes | |
username | string | yes | The watched handle, company slug, or keyword search expression. |
type | "person" | "company" | "keyword" | yes | |
mode | "posts_only" | yes | |
total | integer | yes | How many rows this response carries. |
posts | object[] | yes | |
└urn | string | yes | |
└url | string | yes | |
└text | string | yes | NULL, never missing, when the provider served a post without any — normal for an image or video post. Read `null`; do not test for the key. |
└postedAt | string <date-time> | yes | When the post was published, served exactly as stored. On a person or company watch it is the instant the post's activity URN encodes (or, for a post keyed by its ugcPost or share id, the instant that id encodes) wherever the URN decodes — posts stored before the September 2026 worker release included, whose stored time was guessed from a relative label such as "1d" — else the time stored. A KEYWORD search's result has no time field, so a keyword post's is the instant its activity URN encodes (a LinkedIn activity id carries its creation time in its top 41 bits), recorded since the September 2026 worker release that added it; a keyword post recorded before that is NULL here and is not back-filled. Never `firstSeenAt`. |
└postedAtTimestamp | integer | yes | The same instant in epoch milliseconds, as on GET /api/v1/profile/{username}/posts. Null exactly when `postedAt` is. |
└totalReactionCount | integer | yes | Reactions on the post AS THE KEYWORD SEARCH REPORTED THEM when the run found it (the result's `numReactions`) — a count, not the people, under the same name and type GET /api/v1/profile/{username}/posts uses. Refreshed only when a later run finds the post again (a kept post the budget never reached is re-found); never a later live reading. NULL, not 0, when the search stated no count; NULL on every keyword post found before the worker release that added these fields (September 2026; not back-filled). ON A PERSON OR COMPANY WATCH IT IS WHAT THE WATCH SAW WHEN IT FIRST RECORDED THE POST, never refreshed; NULL on watch posts recorded before the September 2026 worker release that added it (not back-filled). |
└commentsCount | integer | yes | Comments on the post as the keyword search reported them when the run found it (`numComments`) — a count, not the commenters. 0 is a stated zero, a post nobody had commented on; NULL is a post the search said nothing about, and is never read as 0. NULL on every keyword post found before the worker release that added these fields (September 2026; not back-filled). ON A PERSON OR COMPANY WATCH IT IS WHAT THE WATCH SAW WHEN IT FIRST RECORDED THE POST, never refreshed; NULL on watch posts recorded before the September 2026 worker release that added it (not back-filled). |
└contentType | "VIDEO" | "IMAGE" | "JOB" | "LIVE_VIDEO" | "DOCUMENT" | "COLLABORATIVE_ARTICLE" | null | yes | The post's kind, read from the search result's media exactly as GET /api/v1/profile/{username}/posts reads it — the same vocabulary as the keyword search's own contentType filter. NULL for a text post and for media with no value in this vocabulary (an article, a poll); NULL on every keyword post found before the worker release that added these fields (September 2026; not back-filled). ON A PERSON OR COMPANY WATCH IT IS WHAT THE WATCH SAW WHEN IT FIRST RECORDED THE POST, never refreshed; NULL on watch posts recorded before the September 2026 worker release that added it (not back-filled). |
└author | object | yes | WHO WROTE THE POST, from the search result's `actor` — the field for telling a company page's post from a person's, and a person's headline usually names their company. ALWAYS AN OBJECT with all three keys: a member is null when the search did not state it, and all three are null on every keyword post found before the September 2026 worker release that added it. ON A PERSON OR COMPANY WATCH THIS IS ALWAYS NULL: the watched source is the post's author. |
└name | string | yes | The author's display name: a company page's name, or a person's first and last name. |
└url | string | yes | The author's LinkedIn URL as the search gave it (a /company/ or /in/ URL), or, for a person the search gave no URL for, the /in/ URL of their public handle. Null when it gave neither — never built from a member URN, and never for a company page. |
└headline | string | yes | A person author's LinkedIn headline when the search supplied one. Always null for a company page, which has none. |
└firstSeenAt | string <date-time> | yes | When this system first saw the post — the instant it was charged for. |
| Status | Meaning | Example error |
|---|---|---|
| 400 | The id is not a UUID, or `limit` is outside 1-200. | |
| 404 | No such source for this team, or the source is not a posts-only watch (`code: "not_posts_only"`, whose message names where that kind's posts ARE reported). |
Leads
List tracked sources
/api/v1/sourcesThe LinkedIn people, company pages, individual posts, and keyword searches this team tracks as lead sources. Create a person or company source with the enrich endpoints (saveTrackedProfile:true) and delete one with DELETE /api/v1/profile|/company/{username}. Tracked POSTS are created with POST /api/v1/post/track (or the dashboard); they are listed here, filterable with ?type=post, and their engagers are returned by GET /api/v1/leads like any other source's. Untrack one with DELETE /api/v1/post/{urn}. Their sync status, webhook config and ICP config are reached by SOURCE ID at /api/v1/sources/{id}/sync, /webhook and /icp — the `id` of each entry below. The username-keyed /sync, /webhook and /icp routes cover person and company sources only, so a post's URN 404s there. A keyword search reaches all three by source id twice over: at /api/v1/keyword/{id}/webhook, /icp and /sync, and at the kind-agnostic /api/v1/sources/{id}/... routes, which take the same id. KEYWORD SEARCHES are created with POST /api/v1/keyword/track (or the dashboard); they are listed here, filterable with ?type=keyword, and their engagers are returned by GET /api/v1/leads the same way. Untrack one with DELETE /api/v1/keyword/{id}. Their /sync, /webhook and /icp are reachable by source id, on their own routes and on the kind-agnostic /api/v1/sources/{id}/... ones alike. Their AI key is NOT set through the API — it is stored once per team per provider in the dashboard's AI filtering panel, on Keyword Engagement (the same panel that sets the provider and prompt, both when creating a search and when editing one), and the create route rejects a key outright rather than ignoring it. THERE IS NO SEPARATE INTEGRATIONS PAGE — this doc used to point at one, and no such page exists. Each source's `id` is the `profileId` GET /api/v1/leads filters by. By DEFAULT this lists only ACTIVE sources; `?includeInactive=true` adds the untracked ones, each carrying `status: "inactive"`. That flag is how a stopped keyword search is found again — its leads are kept, and without it neither its id nor its lead count is reachable.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
type | "person" | "company" | "post" | "keyword" | no | Filter to one kind of source. An unrecognised value is rejected with 400. `post` matches tracked LinkedIn posts, which are created in the Cornersight dashboard rather than through this API. |
includeInactive | boolean | no | Also list UNTRACKED sources. Untracking is a soft delete on every kind — the row is deactivated rather than removed, because leads reference it — so an untracked source is a real row this list simply stops naming. Each one comes back with `status: "inactive"`, so the two can never be confused; combine with `?type=keyword` to list every keyword search the team has ever had. WHY YOU WOULD WANT IT. Untracking a keyword search KEEPS the leads it captured, and without this the search's id becomes undiscoverable — so a per-search breakdown silently omits every search the user has stopped. This is the public equivalent of what the dashboard's own keyword leads view does; `GET /api/v1/leads?sourceKind=keyword` is the same answer in aggregate and needs no ids at all. THIS LISTS SOURCES; IT DOES NOT SERVE LEADS — AND THE LEADS HAVE THEIR OWN FLAG. Listing an untracked person, company or post source tells you it existed and when. Its kept leads are read back by passing the SAME PARAMETER to the endpoints that serve them: `GET /api/v1/leads?includeInactive=true` and `GET /api/v1/engagers?includeInactive=true`, which also make the id you got here resolve there instead of answering 404. WITHOUT that flag those leads stay out of both endpoints, which is the default and matches the dashboard — the dashboard has no such opt-in at all, so nothing this flag does changes what a customer sees on screen. Keyword searches need no flag anywhere: their leads stay readable after untracking, on every surface. READING IS NOT ACTING: an untracked source still cannot be pushed, and its `/webhook` and `/icp` routes still 404, whatever flag you pass. An unrecognised value (`1`, `yes`) is a 400 rather than a silent false. |
Responses
200 — The team's tracked sources, newest first.
| Field | Type | Required | Description |
|---|---|---|---|
sources | object[] | no | Every source this team tracks, newest first. The fields below are the source row; `lastRun` is populated only for keyword searches. |
└id | string <uuid> | no | The tracked source's id. This is the value GET /api/v1/leads accepts as `profileId` — use it to scope a leads query to this source. |
└username | string | no | LinkedIn handle. This is what DELETE /api/v1/profile/{username} or /company/{username} takes to untrack the source. |
└url | string | null | no | LinkedIn profile/company URL. |
└type | "person" | "company" | "post" | no | What kind of source this is. `person` and `company` are tracked through the enrich endpoints (saveTrackedProfile:true); `post` is a single LinkedIn post tracked from the Cornersight dashboard — this API can list and filter posts but cannot create them. |
└displayName | string | null | no | |
└avatarUrl | string | null | no | |
└status | string | null | no | |
└leadsReady | boolean | no | Whether this source's leads are currently readable — NOT whether any leads exist. On person, company and post sources it is false only while a sync or its enrichment is in flight, so it does track sync state there. On a KEYWORD search it is true from creation and never indicates the first sweep has happened — the sweep begins within about a minute of creation, but during that window and always, use lastRun to ask whether it has run. On no source kind does true mean leads were found: a source that swept and captured nothing is also true. An absent-profileId /api/v1/leads query only reads sources where this is true. |
└isTrialProfile | boolean | no | Trial sources cannot be untracked (DELETE returns 403). |
└lastSyncedAt | string | null <date-time> | no | |
└nextSyncAt | string | null <date-time> | no | When this source's next sync is due (daily cadence). null while a sync is currently running — the same value GET /{type}/{username}/sync reports, so an overview needs no per-source call. Null too for a keyword search whose SCHEDULE has ended (see `schedule.stoppedAt`), and for every source that is not active — UNTRACKED (`status: "inactive"`, listed with ?includeInactive=true) or `paused`: in each case there is no next run to name. |
└createdAt | string <date-time> | no | |
└icp | object | no | The source's ICP criteria (the filter behind isIcp / icpOnly). Read-only here; set via PUT /api/v1/{type}/{username}/icp. rules is [] when there is no ICP filter. |
└matchMode | "all" | "any" | yes | |
└rules | object[] | yes | |
└column | string | yes | |
└operator | string | yes | |
└value | string | yes | |
└creditCapPerSync | integer <int32> | no | The most credits ONE SYNC of this source may spend, where the capture cap counts lead rows while billing charges once per new person per source (tracked_profiles.credit_cap, migration 147). `null` means NO LIMIT, which is what every source without one holds — there is deliberately no default. Set it with `creditCapPerSync` on POST /api/v1/enrich/{profile,company} (with saveTrackedProfile: true) or POST /api/v1/post/track, and CHANGE it without re-syncing with PATCH /api/v1/{profile,company}/{username}. ⚠️ RENAMED from `creditCap` in 4.0.0, because `config.creditCap` on this SAME response is a keyword search's per-RUN cap — one response object, two different numbers, one name, told apart only by knowing the source's kind. The keyword field was published first, so this one moved; there is no alias. A sync that ENDED on this limit reports `stoppedBy: "credit_cap"` on GET /api/v1/sources/{id}/sync — that is a fact about one RUN, so it lives on the run rather than here, and reading it for every row of this list would be one query per source. ⚠️ ABSENT ON A KEYWORD SOURCE: a keyword search's per-run limit is `config.creditCap` (keyword_searches.credit_cap) and this column is ignored for it, so there is exactly one place to read a given source's limit. PER SYNC, EVERY SYNC: it bounds EACH sync of that one person, company page or post — the capture cap counts lead rows, while billing charges once per new person per source — not the first pull only and not the life of the source, and there is NO ESTIMATE, NO CONFIRMATION GATE AND NO TEAM CEILING behind it: nothing prices a sync before you set the cap, raising or lowering it never needs `confirmSpend`, and the team's `dailyCeiling` does not count a credit of it. ⚠️ A KEYWORD SEARCH'S `creditCap` IS THE OTHER FIELD AND HAS ALL THREE: it is the daily bound on a RECURRING SWEEP, POST /api/v1/keyword/estimate prices it as `estimatedDailyMax`, `confirmSpend` gates a create or a raise with a `409 spend_confirmation_required`, and the team's `dailyCeiling` stops it — and `dailyCeiling` COUNTS KEYWORD SPEND ONLY, so no number of profile syncs can ever reach it. |
└captureReplies | boolean | no | Whether this source captures comment-reply authors as leads. Defaults to true; false skips replies before lead writes and credits. |
└enrichLeads | boolean | no | Whether this source's leads are enriched. Defaults to true. false = RAW MODE: engagers are captured and charged exactly like enriched ones (one credit per new person per source, repeats free) but never enriched, and they are read with GET /api/v1/leads/raw — never GET /api/v1/leads, /engagers, exports, webhooks or integrations. Set with `enrichLeads` on POST /api/v1/enrich/{profile,company} (with saveTrackedProfile: true), POST /api/v1/post/track, POST /api/v1/keyword/track, and changed with PATCH /api/v1/{profile,company}/{username} or PATCH /api/v1/keyword/{id}. A change applies to leads captured after it. |
└lastRun | object | no | The outcome of this source's last sweep. `null` for person, company and post sources, which have no sweep of their own. For a keyword search this is always an object; `stoppedBy`, `reason`, `at`, `postsScanned` and `postsKept` are null until the first run finishes, so a keyword search that has never run is distinguishable from a source that can never have one. THESE OPTIONAL FIELDS — `postsHarvested`, `engagersSeen`, `engagersDropped`, `engagersDuplicate`, `leadsWritten`, `providerRowsDropped`, `providerPageLimitReached`, `postsAvailable`, `caughtUp`, `creditsSpent` and `config` — ARE OMITTED RATHER THAN NULL when the last run predates the column that records them, so test for the KEY's presence (`'engagersSeen' in lastRun`) rather than for a value: an absent field means that run did not measure it, while a `0` or `false` is a measurement. This — not leadsReady — is the signal that a keyword search has run: `at` null means it has never swept, and `postsScanned`/`postsKept` distinguish 'ran and found nothing' from 'never looked'. |
└reason | string | no | WHY the run ended, in the words of whatever stopped it — the sentence behind the `stoppedBy` category. For `ai_error` this is a sentence CORNERSIGHT OWNS, naming which of four things went wrong and what to do about it, with `aiErrorCode` beside it as the machine-readable half. It USED TO BE the AI provider's entire response envelope, quoted wholesale (`openai returned 404: {"error": {"message": …, "type": "invalid_request_error", "param": null, "code": "model_not_found"}}`) — multi-line JSON in a one-line string field, whose shape was the vendor's to change without notice and whose only stable token was buried in per-vendor prose. The provider's raw response is now kept in Cornersight's logs and served by nothing. Every OTHER stop is unchanged: a small sweep still explains itself here ("no new posts: 12 matching posts were already captured by earlier runs"). A run whose boolean expression discarded EVERY post it harvested says so here, and whether those posts were marked seen and listed at kept-posts?include=swept. When every term's walk reached the end of what LinkedIn's search returned, it says that too — `the search reached the end of what LinkedIn's search returned for its terms and found no exact matches for this search's expression` — and advises broadening the terms in a new search; a walk stopped by the 2,000-post scan ceiling, the 20-page bound or a page of posts earlier runs had seen makes no such claim, because more results may lie beyond it. `null` before the first run, and `null` when whatever stopped the run recorded no text — a normal `credits` or `exhausted` stop usually has none. |
└aiErrorCode | "model_not_found" | "invalid_key" | "rate_limited" | "out_of_credit" | "provider_error" | no | WHICH KIND of AI-filter failure this was, as a stable token to branch on — the machine-readable half of `reason` for an `ai_error`. PRESENT ONLY when the run failed at the AI filter; absent (not null) otherwise, including for every run that stopped for any other reason. Split by WHO MUST ACT: `model_not_found` — the search names a model the provider will not serve, so clear `config.aiModel` to use the default or set one the account can reach; `invalid_key` — the stored key was refused, or none is saved for that provider; `rate_limited` — the provider is throttling or that key's quota is spent, and the next daily run will try again; `out_of_credit` — the provider account behind the key has no credit left (an HTTP 402, Anthropic's "credit balance is too low", OpenAI's `insufficient_quota`, xAI's credits-exhausted response), so the owner must add credit or check billing with the provider; the key itself is fine and must not be rotated; `provider_error` — THE PROVIDER'S OWN FAILURE (its 5xx, its outage), which is NOT a credential problem and must never be reported as one. The first four are the search owner's to fix; `provider_error` asks only for a retry. Absent on runs that predate this field. |
└stoppedBy | "budget" | "credits" | "exhausted" | "error" | "ai_error" | "team_cap" | "lead_cap" | "capture_empty" | "post_limit" | null | no | Why the last run ended. `budget` - NO LONGER PRODUCED: it named a per-run post limit that was retired on 30 September 2026, and appears only on a search whose last run predates that. `credits` - it spent creditCap credits, which is the usual stop for a broad search because a credit is charged per new person for this search; repeat engagements are free. On a `posts_only` search it means `creditCap` was BELOW `postsPerSync` and was reached. `post_limit` - a `posts_only` search bought the `postsPerSync` new posts it may buy in a day. An ORDINARY ending, and the setting to raise for more posts is `postsPerSync`, not `creditCap`: in the usual case `postsPerSync` is the lower of the two, so the credit cap was never reached. `exhausted` - the provider walk ended without a capture cap. `providerPageLimitReached: true` means a safety bound on the walk — 20 provider pages per term, or the run's 2,000-post scan ceiling — ended it before later pages were proven empty; false only says that bound did not fire, because an all-seen RELEVANCE page can also stop before later unseen posts. `error` - the sweep itself failed. `ai_error` - the team's own AI credential failed, which is the only value that asks the team to do something. `team_cap` - the TEAM's daily ceiling was reached, not this search's own cap: the run stopped AT the ceiling as a partial run rather than a refusal, the day's remaining searches are skipped with the same marker, and everything runs again tomorrow — so it is the team's settings, not this search's creditCap, that a reader should look at. `lead_cap` - this SOURCE's own lead cap is spent, which is neither a credit cap nor a ceiling anyone can raise in Settings: a keyword search on a trial holds at most 250 leads, and once it has them the daily sweep is skipped before any search, harvest or charge, so every counter reads zero. It is an ORDINARY ending and not a failure - the search is doing exactly what the plan allows. `leadsWritten` tells a skip (0) from a run that reached the cap part-way through (non-zero), the same way it does for `team_cap`, and it stays this way until the cap rises or leads are removed. `capture_empty` - the sweep HARVESTED REAL POSTS and every engager fetch answered and returned NOBODY, which is a statement about the CAPTURE rather than about the posts: between 12 and 14 September 2026 the upstream engager endpoints answered 200 with no rows for three days, every run of every search reported itself a clean `exhausted`, and it reached us as a customer complaint rather than as an alert. It is a FAILURE ending like `error` — which is the value it was recorded under before it had its own name — so the run's sync job is failed and `sync.failed` fires. READ IT WITH THE COUNTERS BESIDE IT: `postsHarvested` is the N in the reason's "harvested N posts, captured nobody" and `engagersSeen` is the measured 0, so the state is machine-readable without parsing the sentence. ⚠ A SEARCH THAT FOUND NOTHING NEVER REPORTS IT: a run that harvested NO posts has no harvest to have captured nobody from, and stays a clean `exhausted` with every counter at 0. `null` before the first run. |
└at | string <date-time> | no | When the last run finished. `null` before the first run. |
└postsScanned | integer | no | Posts the last run considered that it had NOT SEEN BEFORE. Not a count of posts matching the term: a search remembers everything it has already swept, so its FIRST run reads everything it can reach and later runs report only what the window has produced since — a healthy established search reporting 1 or 2 here is normal, not a failed query. When a run is small because earlier runs already took the matches, `reason` says how many were already captured; a run that found nothing AND has nothing already swept is the case where the term really did match nothing. OUR REQUEST DOES NOT VARY WITH THE TERM — it reaches the provider verbatim, which the search body proves — BUT WORD-COUNT ADVICE IS RETRACTED AGAIN 2026-09-24: seven brand-new single-word and phrase searches all reached the 25-post limit they ran under then; a fresh single word scanned 179 under a 250-post limit. Both were first runs, so no prior seen-set explains the measurement. The older 2026-09-09 `sales` versus `sales team` historical run remains unproven: it predates `providerRowsDropped`, and it did not reproduce. Since 30 September 2026 no setting limits how many posts a run considers: a run reads up to 2,000 posts, and the credit cap can still stop capture sooner. On a current run, read `providerRowsDropped`: positive means uncapturable provider rows were skipped, 0 means no provider row was skipped, and absent means unmeasured. A nonterminal all-dropped page is skipped and the walk continues; `providerPageLimitReached` says whether the 20-page safety bound prevented proof of exhaustion. The worker never converts a `ugcPost` or `share` identifier into an `activity` URN and never widens a term. The dashboard keyword row shows an approximate first-page provider match estimate summed across terms (shared posts may double-count and totals can drift), plus a caught-up message when every term ended on posts swept by earlier runs. The same measured diagnostics are returned on this response as `postsAvailable` (approximate first-page totals summed across terms, so shared posts may count twice and totals can drift) and `caughtUp` (true when every term ended on posts swept earlier); both are omitted when unmeasured. A POST A BOOLEAN `expression` DISCARDED IS COUNTED HERE TOO — the run read it in order to discard it — and is NOT counted in `postsKept`; `lastRun.discardedByExpression` is how many of this number those were, and kept-posts?include=swept names them one by one. `null` before the first run. WHAT IT COUNTS, EXACTLY: every post the run READ — the posts that reached its filters, INCLUDING the ones a boolean expression discarded, and after the 2,000-post scan ceiling has trimmed the survivors. So on a run with no AI filter postsScanned = postsKept + discardedByExpression exactly (`discardedByExpression` absent counts as 0); with an AI filter, postsScanned = postsKept + discardedByExpression + the posts it rejected, and on an `ai_error` run the remainder also holds posts it never decided. Runs recorded before 2026-09-21 counted only the posts that SURVIVED the expression, which is how an old record can show postsScanned 1 beside discardedByExpression 9. |
└postsKept | integer | no | Posts that survived the AI filter, or all scanned posts when no filter is set. `null` before the first run. `postsScanned` -> `postsKept` is the FILTER's before/after; `postsHarvested` is a separate question — how far the run reached. ON A SEARCH WITH A BOOLEAN `expression` THE DROP IS TWO STEPS, NOT ONE: the expression discards first (`discardedByExpression` counts those, and they are in `postsScanned`) and the AI filter judges what is left, so `postsScanned` minus `postsKept` is the two together and only the swept list says which post went to which. |
└postsHarvested | integer | no | Posts the last run actually HARVESTED — reached and captured — before it stopped. The capture loop breaks at `creditCap` (or, on a `posts_only` search, `postsPerSync`), so this can be far below `postsKept`: a run that kept 25 posts but hit its credit cap after the first harvests 1. Counts a post whose engagers were ALL already captured on a prior run (0 new leads) because the sweep still fetched and deduped them — it measures REACH, not lead yield, and a harvested-to-zero post is not the same as one the cap never reached. OMITTED (the key is absent, never 0) when the last run predates this field — a 0 would falsely claim it harvested nothing. |
└engagersSeen | integer | no | Engagers the last run's captures received, summed across posts — the denominator for the three buckets below. HOW TO READ THE FOUR TOGETHER: engagersSeen 0 means the posts were quiet; engagersDuplicate > 0 means those people were already captured (free, not a loss); engagersDropped > 0 means real people were seen and discarded. ⚠ KNOWN GAP — engagersSeen 0 IS NOT PROOF THE POSTS WERE QUIET. It counts only what the provider pagers RETURNED, and those pagers discard rows of their own beforehand (they tally them in local counters they log and throw away), so a post whose engagers were all dropped upstream reports seen 0 and is indistinguishable here from a post nobody engaged with. Read seen 0 as "the capture received nothing", never as "nothing was there". Closing that needs a pager-level drop count, which is not yet recorded. OMITTED (never 0) when the run predates this field. OMITTED (like engagersDropped and engagersDuplicate) for a run that fetched no engagers at all — a keyword search with captureEngagers false, or a posts-only keyword search (mode posts_only), which captures no people — because such a run did not measure them; its post authors are reported as postAuthorsCaptured. |
└engagersDropped | integer | no | Engagers the last run saw but could not turn into a lead. NARROWER SINCE 2026-09-08: an engager with no public handle is no longer dropped — the lead is keyed on their member URN — so what remains here is an engager with NO identity at all (neither handle nor URN) and an ORGANISATION page, which is not a person and can never be a lead. A non-zero value is therefore mostly "these were not leads" rather than "we lost people"; the loss it used to measure is the reason the URN fallback exists. A measured 0 is reported as 0; the field is OMITTED entirely when the run predates it, so absent and zero never read the same. |
└engagersDuplicate | integer | no | Engagers already accounted for without a new charge: both exact stored-row collisions and new engagement rows by a person this source already charged. `repeatEngagements` is the latter subset. OMITTED when the run predates this field. |
└repeatEngagements | integer | no | New lead rows by people already charged for this source in an earlier engagement. These rows are free, and are also included in `engagersDuplicate`. OMITTED for runs before this counter was recorded; a measured zero is 0. |
└leadsWritten | integer | no | Lead rows the last run actually inserted, including free repeat engagements; this can exceed `creditsSpent`. OMITTED when the run predates this field. NOTE the four numbers are buckets, not a closed identity: engagersSeen can exceed leadsWritten + engagersDropped + engagersDuplicate, and the remainder is the per-post allowance clamp leaving a post's surplus engagers for the next run. |
└postsFilteredOut | integer | no | How many harvested posts this run discarded because they failed the AND/NOT terms of the search's `config.expression`. OMITTED when the search has no expression, and also on runs predating the field; PRESENT AND 0 when an expression bound the run and discarded nothing — those are different answers and must not be collapsed. THIS IS THE FIELD THAT TELLS A NARROW EXPRESSION FROM A BROKEN SEARCH: `postsHarvested: 0` with `postsFilteredOut: 47` is an expression to rewrite; `postsHarvested: 0` with the key absent or 0 is a search that found nothing. Such a run ends `stoppedBy: "exhausted"` — nothing broke — with `reason` saying so in a sentence. Discarded posts ARE counted in `postsScanned` — the run READ them, which is how it discarded them — and are NOT counted in `postsKept`. Each one is also LISTED: GET /api/v1/sources/{id}/kept-posts?include=swept returns a row per discarded post with `outcome: "discarded"` and a `reason` naming the part of the expression it failed (`missing phrase "sales ops"`, `contains recruiter`). Until 2026-09-21 they were counted in neither and listed nowhere, so a run that harvested a page of posts and kept one reported `postsScanned: 1` and the only record of the rest was this number. They ARE marked seen, so the next run does not pay to harvest them again, and they cost no credits. |
└discardedByExpression | integer | no | THE SAME NUMBER AS `postsFilteredOut`, under the name that says WHAT discarded the posts. The run summary reads “searched N, discarded M, kept K” off this one: `postsScanned` is N, this is M and `postsKept` is K. `postsFilteredOut` is the older name and stays because callers already branch on it, but “filtered out” is ambiguous in a sweep where an AI filter also filters. ONE COLUMN READ, SERVED TWICE: the two are always both present or both absent and can never differ. Present if and only if an AND/NOT expression bound the run — 0 included, absent for a search that has no expression and for runs predating the field. On a run with no AI filter the three reconcile exactly — N = K + M — and GET /api/v1/sources/{id}/kept-posts?include=swept returns M `discarded` rows, one per post, even when K is 0. A run that stopped before applying its expression (a cap skip, an untrack, an unreadable plan) omits this key rather than repeat the previous run's count. |
└providerRowsDropped | integer | no | Provider RESULT ROWS the last run skipped during post normalisation because they did not carry a capturable `urn:li:activity:*` id. This counts rows across terms and pages, not necessarily unique posts. Cornersight does not relabel `ugcPost` or `share` ids because those ids are not the corresponding activity id and doing so can attribute another post's engagers to this source. The collector CONTINUES past a NONTERMINAL all-dropped page, bounded by 20 pages, instead of treating it as provider exhaustion; a page the provider marks `last` still ends the walk without buying nonexistent pages. PRESENT AND 0 when measured and nothing was dropped; OMITTED when the run predates migration 159 or made no measured provider search. |
└providerPageLimitReached | boolean | no | Whether any keyword term in the last run reached the provider walker's 20-page safety bound before proving later pages empty. It is also true when the run's merged posts reached its 2,000-post scan ceiling with more left to read. Read this with `stoppedBy`: true plus `exhausted` means later pages may still contain capturable posts; true plus `credits` or `post_limit` means the capture's real cap still ended the merged run and remains authoritative. PRESENT as true or false after a measured provider walk; OMITTED when the run predates migration 159 or made no provider search. False only says the 20-page bound did not fire; an all-seen page under RELEVANCE ordering can still stop before later unseen posts. |
└postsAvailable | integer | no | The provider's approximate first-page total for the search terms when this run measured it. Terms are summed, so shared posts can be double-counted and provider totals can drift. OMITTED when migration 165 is unavailable or no provider total was returned. This is an estimate, not a promise of unique or capturable posts. |
└caughtUp | boolean | no | Whether every searched term ended on posts already swept by earlier runs. PRESENT as true or false when measured; omitted when the run predates migration 165 or no provider search was made. A measured false is not interchangeable with a missing value. |
└postAuthorsCaptured | integer | no | Author lead rows this run wrote — post authors who were new people (charged, included in creditsSpent) or free repeats (included in repeatEngagements). PRESENT only when the run captured post authors, and then present even at 0; omitted for an engagers-only run and for runs before migration 176. Author rows are also counted in leadsWritten. |
└companyAuthorsSkipped | integer | no | Kept posts in this run whose author was a COMPANY PAGE, so no author was captured and nothing was charged. PRESENT only when the run captured post authors, and then present even at 0. |
└creditsSpent | integer | no | What the last run COST, in credits: newly chargeable people for this source, not all engagement rows. Repeat engagements can make `leadsWritten` larger without increasing this count. Before the distinct spend counter existed, historical runs use their lead-row count as a legacy estimate. This is capture-side committed spend; the enriching ledger may post charges later, so GET /api/v1/credits/usage can lag it. |
└config | object | no | THE SETTINGS THIS RUN ACTUALLY USED, snapshotted by the run itself — as against the source's own `config`, which is what the search is set to NOW and what its NEXT run will use. The two differ whenever the search was edited after this run started: a run reads its configuration once, when the sweep begins, and never re-reads it. That is what makes a run's counts interpretable. A run reporting `leadsWritten: 21` beside a stored `creditCap` of 5 is a cap overrun IF AND ONLY IF this object also says 5; if it says 100, the run finished inside the cap it was given and the smaller cap arrived afterwards. OMITTED (the key is absent, never null) when the last run predates this field — a run's settings are not knowable after the fact, and filling them in from the current columns would manufacture exactly the false certainty this exists to remove. |
└creditCap | integer | no | The credit cap that run was bound by — the number to read `creditsSpent` against; lead rows can exceed credits when repeat engagements are free. |
└captureMode | "depth" | "breadth" | null | no | The capture mode that run used, resolved: a search that has never chosen one reports `depth`, which is what the run loop does with it. |
└maxEngagementsPerPost | integer | no | The per-post ceiling that run applied, or `null` for no explicit ceiling — null is a value here, not a missing measurement. |
└creditsSpentToday | integer | no | Credits this KEYWORD SOURCE has spent so far TODAY, over the UTC calendar day — the same day GET /api/v1/credits/usage dates `byDate` and `bySourceId` by. Present ONLY on keyword sources; the other three kinds have no sweep of their own. DISTINCT FROM `lastRun.creditsSpent`, which is ONE run: the two differ whenever a search ran more than once today, which is exactly the case a per-run number cannot explain. Summed across a team's sources this is `spentToday` on GET /api/v1/credits — the number the team's `dailyCeiling` is enforced against — because one grouped read serves both. OMITTED, never `0`, when the spend ledger could not be read; a `0` is a measurement. |
└config | object | no | WHAT THIS KEYWORD SEARCH IS SET TO: the numbers that bound each of its runs, and the AI filter that decides which of the posts they find are kept. Present ONLY on keyword sources — the other three kinds have no sweep of their own and no key at all, not a null one. Always an object on a keyword source, with null members if its configuration row could not be read, so "not a keyword search" and "a keyword search we could not read" never collapse into each other. These were write-only before this: settable at creation on all four surfaces (dashboard, API, CLI, MCP) and editable on two, and returned by no read operation — so a caller could not check the cap it had set, could not check WHICH MODEL its filter runs on, and an agent asked "what is my budget?" had nowhere to look. THIS IS THE NEXT RUN'S CONFIGURATION. What the LAST run used is `lastRun.config`, and the two disagree whenever the search was edited after that run began. |
└creditCap | integer | no | Enriching credits one run may spend before it stops (`stoppedBy: "credits"`). PER RUN, and the real bound on spend — a credit is charged per new person for this search; repeat engagements are free. 100 by default. |
└captureMode | "depth" | "breadth" | null | no | How the credit cap is spent across a run's posts. `depth` (the default, and what a search that never chose reports) lets each post spend the whole remaining cap; `breadth` shares it across posts and redistributes what they cannot use. |
└maxEngagementsPerPost | integer | no | Breadth only: the most engagements one post may contribute to a run. `null` means no explicit ceiling — fair-share the whole cap — which is a setting, not an unknown. |
└captureEngagers | boolean | no | Whether each run captures the people who liked or commented on its kept posts (migration 176). Default true. Null on a `posts_only` search, which captures no people, and when the search has no settings row. |
└capturePostAuthors | boolean | no | Whether each run captures the person who WROTE each kept post, as an `Author` lead — one credit per new person like any engager; company-page authors are skipped and not charged (migration 176). Default false. Null on a `posts_only` search and when the search has no settings row. |
└aiProvider | "openai" | "grok" | "gemini" | "claude" | null | no | Which stored credential filters this search's posts, or `null` for no AI filter — every post the terms find is then captured. NEVER THE KEY ITSELF: the key is held once per team per provider, encrypted, and is returned by no endpoint. Set with `aiProvider` on POST /api/v1/keyword/track. |
└aiModel | string | no | The model this search PINS, exactly as it was sent to POST /api/v1/keyword/track — or `null` for "no model chosen", which is a setting and not a missing value. NULL AND A MODEL ID ARE DIFFERENT REQUESTS and are deliberately not collapsed the way `captureMode` is: null follows the provider default as vendors retire models, an explicit id stays put and will 404 at the provider once that id is gone. Read `aiModelEffective` beside it for the id a run actually sends. `null` whenever `aiProvider` is null, since there is then no filter to configure. |
└aiModelEffective | string | no | THE MODEL THE NEXT RUN WILL ACTUALLY SEND: `aiModel` when this search pins one, otherwise the provider's default — openai `gpt-6-luna`, grok `grok-4.3`, gemini `gemini-3.5-flash-lite`, claude `claude-haiku-4-5-20251001`. Resolved by the SAME code path the filter itself calls, so it cannot disagree with what is sent, and it follows a default change rather than restating a constant that has moved — the claude default has a published retirement floor of October 2026. `null` only when `aiProvider` is null: no filter, so no model. This is the field to quote when a search's results look like the wrong model ran. |
└estimatedDailyMax | integer | no | THE MOST ONE DAY OF THIS SEARCH CAN COST, in enriching credits — the same field POST /api/v1/keyword/track returns when a search is created, recomputed here from the caps the NEXT run will be bound by, so a search created or edited anywhere can be read back. Produced by the one shared function every surface quoting a spend estimate reads; the formula is written out on POST /api/v1/keyword/track. OMITTED — not `0`, not `null` — when `creditCap` is missing or is not a positive whole number. Test for the KEY's presence, never for a value. |
└daysToExhaustAtCap | integer | no | WHOLE days the team's remaining enriching-credit balance funds at that daily maximum. `0` is a real answer and the warning one: the balance cannot fund one whole day at this cap, so the next sweep is already the one that gets cut short. OMITTED — never zero, never infinite — when there is no rate to divide by, or when the balance could not be read at all: that read is best effort and its failure removes this key rather than failing the listing. Test for the KEY's presence. |
└expression | string | no | The boolean expression this search was created from, in CANONICAL form — operators upper case, the implicit OR written out, and a multi-literal group bracketed when there is more than one group, e.g. `(hiring AND NOT recruiter) OR fundraising`. It is NOT an echo of what was sent: the canonical form is where the precedence is visible. It is also the one bracketed form the create accepts: sent back unchanged as `expression`, it compiles to the same search. `null` for every search created from a plain `keywords` list, and `null` too when the stored plan is a shape this build cannot read. `keywords` beside it is always the terms actually searched for, whichever way the search was made. Set with `expression` on POST /api/v1/keyword/track; it CANNOT be changed afterwards — PATCH /api/v1/keyword/{id} refuses it, because the seen-set is keyed to the SEARCH and a new expression would inherit the posts the old one rejected. |
└schedule | object | no | WHEN THIS KEYWORD SEARCH STOPS. Present ONLY on keyword sources — the other kinds have no sweep of their own — and always present on one. Every member at its default (`runOnce: false`, the rest null, `runsCompleted: 0`) is a search with no bounds, which is the unbounded daily cadence and is what every search created before this feature has; it is an ANSWER, not a missing value. |
└runOnce | boolean | no | Harvest once, then stop. `false` is the unbounded daily cadence. |
└endAt | string <date-time> | no | The UTC instant after which it stops scheduling, or null for no end date. |
└maxRuns | integer | no | The run budget, or null for none. See `maxRuns` on POST /api/v1/keyword/track for what counts as a run. |
└runsCompleted | integer | no | How many runs have COUNTED. See `maxRuns` for what counts; raise `maxRuns` above this number with PATCH /api/v1/keyword/{id} to restart a search that stopped at it. |
└stoppedAt | string <date-time> | no | When the scheduler stopped scheduling this search, or null while it still recurs. While this is set, `nextSyncAt` is null: there is no next run. |
└stoppedReason | "run_once" | "end_at" | "max_runs" | null | no | Which control stopped it. DISTINCT FROM `lastRun.stoppedBy`, which says how the last RUN ended and is untouched by a schedule ending — a search can stop scheduling after a run that ended `exhausted` with a hundred leads, and overwriting that run's outcome would destroy the answer to "how did my last run do". Null WITH a `stoppedAt` set means the reason could not be stored. |
└filters | object | no | The provider-side TARGETING filters this keyword search was created with. Present ONLY on keyword sources, and only when at least one filter is set — a search using none has no `filters` key at all, so sources that do not use them are unchanged. Each value is the array of LinkedIn URNs (or, for authorKeyword, plain words) supplied at creation. These were previously write-only: settable on every surface and returned by none, so a caller could not verify what they sent. Read-only here; they are set at creation and corrected through the dashboard's edit route. |
└authorIndustry | string[] | no | Industry ids (`urn:li:industry:96`): only posts whose author is in one of them were searched. Present only when non-empty. Reported in the canonical wrapped form whichever spelling was sent. |
└authorCompany | string[] | no | LinkedIn company URNs: only posts whose author currently works at one of them. Present only when non-empty. |
└authorKeyword | string[] | no | Plain words matched against the AUTHOR (headline, job title), not the post text — the only filter here that takes words rather than URNs. Present only when non-empty. |
└fromPerson | string[] | no | Member ids (`urn:li:person:ACoAA…`): only posts written by those people. Present only when non-empty. Reported in the canonical wrapped form whichever spelling was sent. |
└fromCompany | string[] | no | LinkedIn company URNs: only posts published by those company pages. Present only when non-empty. |
└mentionsPerson | string[] | no | Member ids (`urn:li:person:ACoAA…`): only posts that @mention one of them. Present only when non-empty. Reported in the canonical wrapped form whichever spelling was sent. |
└mentionsCompany | string[] | no | LinkedIn company URNs: only posts that @mention one of those pages. Present only when non-empty. |
└firstSyncPosts | integer | no | PERSON AND COMPANY SOURCES ONLY (absent on a tracked post or keyword search). What this source's FIRST sync was set to collect: the latest N posts, or `null` when nothing was chosen (the default, the latest 15). It describes the first sync only - every later sync checks the 4 newest posts - so on a source that has synced it is a record of the setting. Set with `firstSyncPosts` on POST /api/v1/enrich/{profile,company}. Omitted, rather than null, only when the value could not be read. |
└firstSyncDays | integer | no | PERSON AND COMPANY SOURCES ONLY (absent on a tracked post or keyword search). The days window this source's FIRST sync was set to collect (posts published in the last N days, at most 50 or at most `firstSyncPosts`), or `null` for no time limit. First sync only, like `firstSyncPosts`. Set with `firstSyncDays` on POST /api/v1/enrich/{profile,company}. Omitted, rather than null, only when the value could not be read. |
total | integer | no | Number of sources returned. |
| Status | Meaning | Example error |
|---|---|---|
| 400 | A query parameter was present but invalid. Malformed values are rejected rather than silently defaulted; a well-formed value outside the allowed range is still clamped (for example `limit=999` clamps to 100). | Invalid 'object' (expected one of: lead, company) |
Which posts a keyword run swept, kept and harvested
/api/v1/sources/{id}/kept-postsWhich posts a keyword search's LAST RUN swept, kept and harvested. `?include=swept` adds a row per post the run considered, with per-post `engagersSeen` and `leadsWritten` that sum to `lastRun`'s counters — the only surface that answers "my run harvested 25 posts and wrote 21 leads; from which of them?" The post URNs the run swept and kept, with their permalinks. lastRun says a run scanned 25 and kept 2; this says WHICH 2, which is what separates "the filter picked quiet posts" from "the capture failed" when engagersSeen is 0. The URN is the activity URN POST /api/v1/post/reactions takes as `postUrn`, so a caller reading `postsKept: 2, engagersSeen: 0` can pull those two posts' reactions and check for themselves — that round trip is what this endpoint is for. A SEPARATE ENDPOINT ON PURPOSE. GET /api/v1/sources returns every source on every call, and an unfiltered run keeps everything it scans — up to 2,000 URNs from one run, tens of KB with the URLs. Putting that on the list response would make every caller pay for a diagnostic; ask for it here instead. `?include=swept` RETURNS EVERY POST THE RUN CONSIDERED, not only the ones it kept, under `sweptPosts` — a DIFFERENT KEY, so a rejected post can never be read out of `keptPosts`. Each row carries `outcome` and its own per-post numbers. `scanned`: the run stopped before a completed AI verdict or has no verdict for this post — an unfiltered run never produces this value, because it keeps everything it scans. `kept`: kept but NEVER REACHED, because a cap stopped the loop or its capture failed — there is no stored post, so /post/reactions answers 404 for it and `url` is null. `harvested`: captured, and the only outcome that can carry non-zero numbers. `discarded`: either the boolean expression rejected it before AI, or a completed AI filter explicitly rejected it. `reason` names the failed expression clause (for example `missing phrase "sales ops"` or `contains recruiter`) or says `AI filter rejected this post`. THE PER-POST NUMBERS SUM TO `lastRun`: rows = `postsScanned`, `kept` + `harvested` rows = `postsKept`, `harvested` rows = `postsHarvested`, `discardedByExpression` counts only expression-discarded rows, not AI rejects, and sum(`engagersSeen`) / sum(`engagersDropped`) / sum(`engagersDuplicate`) / sum(`leadsWritten`) are that run's four counters. That is how `harvested 25, leadsWritten 21` becomes `these five posts wrote all 21, and these twenty wrote none`: read `leadsWritten` per row, then `engagersSeen` on the zero-lead rows to tell a QUIET post (0 seen) from a LOSSY capture (people seen, none usable). On a run-scoped answer the four numbers are ALWAYS present, including 0; on the prompt-scoped fallback they are ABSENT, because nothing measured them — and an absent number is not a zero. ⚠ `?include=swept` HAS NO FALLBACK: a run that recorded no per-post list answers `sweptPosts: null` with `reason` rather than widening to the prompt. `include=kept` is the default and is unchanged. `scope` SAYS WHICH QUESTION WAS ANSWERED, and a caller comparing the list against `postsKept` must read it. "run" is the list that run itself recorded, in the order it walked them. "prompt" is the fallback for a run that recorded none: every post kept under the search's CURRENT prompt, across every run of that prompt — a WIDER set, which will not match `postsKept`, and which is empty for a search with no AI filter. ⚠ `keptPosts` is NULL, never an empty array, when nothing recorded a kept list — with `reason` saying so. That is a `scope: "prompt"` answer: a run with no AI filter decides nothing, and a run from before the verdict fix recorded nothing. Both KEPT posts, so [] would falsely claim they kept none. An EMPTY ARRAY is a different and real answer, and it only ever appears with `scope: "run"`: that run reached its filter and the filter rejected everything, which `postsKept: 0` says too. DISCARDS ARE ROWS, AND A RUN THAT KEPT NOTHING STILL RETURNS THEM. With `?include=swept`, every post the run considered and rejected is its own row: `outcome: "discarded"`, its `urn`, its `url` (the post's LinkedIn permalink as the search returned it — nothing was stored for a discarded post, so /post/reactions still answers 404 for that URN) and a `reason`. For the boolean expression the reason is the first clause the post failed, in the words of the expression: `missing phrase "sales ops"` (a quoted phrase not present as adjacent words), `missing hiring` (a required term absent) or `contains recruiter` (an excluded term present). For the AI filter it is `AI filter rejected this post`, followed by the provider and model whose verdict it was when that was recorded — `AI filter rejected this post (openai, gpt-4o-mini)`; the model answers only keep or reject, so there is no per-post rationale beyond that. `keptPosts` and `sweptPosts` are therefore different answers for such a run: `keptPosts` is [] and `sweptPosts` still carries one row per discard — a run that kept nothing is NEVER an empty swept list if its expression or AI filter rejected anything. The rows reconcile with `lastRun`: on a run with no AI filter, `postsScanned` = `postsKept` + `discardedByExpression`; with one, the other `discarded` rows (the ones whose reason starts `AI filter rejected this post`) are its rejections. ⚠ `unlistedDiscards` appears only when `lastRun.discardedByExpression` counts posts this list has no row for: a run recorded before 2026-09-21 counted its discards but did not list them (and counted only the survivors in `postsScanned`), and a list at its 2000-row bound keeps the posts the run kept first and loses the tail of its discards. Those rows were never stored and are not reconstructed; a run recorded since then lists every discard. A `url` of null means the post was kept but never HARVESTED — a cap stopped the run before it reached that post — so nothing was stored for it and POST /api/v1/post/reactions answers 404 for that URN. It is the per-post spelling of lastRun's postsKept vs postsHarvested. EACH ROW ALSO SAYS WHAT THE POST IS, so a caller can triage posts WITHOUT BUYING THEIR ENGAGERS: `author` { `name`, `url`, `headline` }, `commentsCount`, `totalReactionCount`, `contentType`, `postedAt` and `postedAtTimestamp` — the names and types GET /api/v1/profile/{username}/posts already uses. They are what the keyword search reported when the run found the post (a company page has a name and a URL and no headline); `postedAt` is the instant the post's activity URN encodes, because the search result carries no time. They are present on EVERY row, on every `scope` and `include`, as null where nothing recorded them: on a row the boolean expression discarded (those posts are not recorded, to keep each run's record small), and on every post found before the September 2026 worker release that added them (not back-filled). A null is never a zero: `commentsCount: 0` is a post nobody had commented on, `commentsCount: null` one the search said nothing about.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | string <uuid> | yes | The keyword source's id, as returned by GET /api/v1/sources. A person, company or post id is a 404: those kinds have no verdicts and never will. |
include | "kept" | "swept" | no | Which SET to return. "kept" (default) returns the posts the run kept, under `keptPosts`. "swept" returns every post the run considered, under `sweptPosts`, each with its outcome and its own engager and lead counts. For a search with no AI filter the two are the same set. An unrecognised value is a 400, not a silently narrowed answer. |
Responses
200 — The posts the run kept, or null when nothing recorded a kept list.
| Field | Type | Required | Description |
|---|---|---|---|
sourceId | string <uuid> | no | |
scope | "run" | "prompt" | no | Which question this answer answers. "run": the list the LAST RUN recorded, in the order it walked the posts — the same set `postsKept` counts. "prompt": the fallback for a run that recorded none, being every post kept under the search's CURRENT prompt across every run of it — a wider set that will not match `postsKept`, and the only scope on which `keptPosts` can be null. |
include | "kept" | "swept" | no | Which set this answer carries, echoing the `include` query parameter (`kept` when it was omitted). PRESENT ON EVERY ANSWER, including the ones whose list is null, so a caller never has to infer which question was answered from which key happens to be populated. |
keptPosts | object[] | no | Null ONLY with scope "prompt", when nothing recorded a kept list — see `reason`. An empty array appears only with scope "run" and is a measured answer: that run kept nothing. |
└urn | string | no | The activity URN, in exactly the form POST /api/v1/post/reactions accepts as `postUrn`. |
└url | string | no | The post's permalink, or null when the run kept this post but never harvested it — in which case /post/reactions answers 404 for its URN, because nothing was stored for it. |
└outcome | "kept" | "harvested" | no | `kept` (kept and never reached — no stored post, `url` null) or `harvested` (captured). A discarded or undecided post is never in this list. PRESENT ONLY on a `scope: "run"` answer served from the run's per-post record (the same row `include=swept` returns, filtered to what was kept); ABSENT on the older run and prompt fallbacks, which recorded no outcome — an absent value is not a zero. |
└reason | string | no | Always null on this list — only a `discarded` row carries a reason, and discarded rows are served under `sweptPosts`. PRESENT ONLY on a `scope: "run"` answer served from the run's per-post record (the same row `include=swept` returns, filtered to what was kept); ABSENT on the older run and prompt fallbacks, which recorded no outcome — an absent value is not a zero. |
└engagersSeen | integer | no | This post's own engagersSeen, as on the swept row. PRESENT ONLY on a `scope: "run"` answer served from the run's per-post record (the same row `include=swept` returns, filtered to what was kept); ABSENT on the older run and prompt fallbacks, which recorded no outcome — an absent value is not a zero. |
└engagersDropped | integer | no | This post's own engagersDropped, as on the swept row. PRESENT ONLY on a `scope: "run"` answer served from the run's per-post record (the same row `include=swept` returns, filtered to what was kept); ABSENT on the older run and prompt fallbacks, which recorded no outcome — an absent value is not a zero. |
└engagersDuplicate | integer | no | This post's own engagersDuplicate, as on the swept row. PRESENT ONLY on a `scope: "run"` answer served from the run's per-post record (the same row `include=swept` returns, filtered to what was kept); ABSENT on the older run and prompt fallbacks, which recorded no outcome — an absent value is not a zero. |
└leadsWritten | integer | no | This post's own leadsWritten, as on the swept row. PRESENT ONLY on a `scope: "run"` answer served from the run's per-post record (the same row `include=swept` returns, filtered to what was kept); ABSENT on the older run and prompt fallbacks, which recorded no outcome — an absent value is not a zero. |
└totalReactionCount | integer | no | Reactions on the post AS THE KEYWORD SEARCH REPORTED THEM when the run found it (the result's `numReactions`) — a count, not the people, under the same name and type GET /api/v1/profile/{username}/posts uses. Refreshed only when a later run finds the post again (a kept post the budget never reached is re-found); never a later live reading. NULL, not 0, when the search stated no count; NULL on every row nothing recorded it for: a post the search's boolean expression discarded (those posts are not recorded, to keep each run's record small) and every post found before the worker release that added these fields (September 2026; not back-filled). PRESENT ON EVERY ROW OF EVERY ANSWER, the older run and prompt fallbacks included: unlike `outcome` and the four numbers it belongs to the POST, not to a run. |
└commentsCount | integer | no | Comments on the post as the keyword search reported them when the run found it (`numComments`) — a count, not the commenters. 0 is a stated zero, a post nobody had commented on; NULL is a post the search said nothing about, and is never read as 0. NULL on every row nothing recorded it for: a post the search's boolean expression discarded (those posts are not recorded, to keep each run's record small) and every post found before the worker release that added these fields (September 2026; not back-filled). PRESENT ON EVERY ROW OF EVERY ANSWER, the older run and prompt fallbacks included: unlike `outcome` and the four numbers it belongs to the POST, not to a run. |
└contentType | "VIDEO" | "IMAGE" | "JOB" | "LIVE_VIDEO" | "DOCUMENT" | "COLLABORATIVE_ARTICLE" | null | no | The post's kind, read from the search result's media exactly as GET /api/v1/profile/{username}/posts reads it — the same vocabulary as the keyword search's own contentType filter. NULL for a text post and for media with no value in this vocabulary (an article, a poll); NULL on every row nothing recorded it for: a post the search's boolean expression discarded (those posts are not recorded, to keep each run's record small) and every post found before the worker release that added these fields (September 2026; not back-filled). PRESENT ON EVERY ROW OF EVERY ANSWER, the older run and prompt fallbacks included: unlike `outcome` and the four numbers it belongs to the POST, not to a run. |
└postedAt | string <date-time> | no | When the post was created, ISO 8601. ⚠ THE KEYWORD SEARCH RESULT HAS NO TIME FIELD: this is the instant the post's activity URN encodes (a LinkedIn activity id carries its creation time, in milliseconds, in its top 41 bits), recorded by the run. Never `firstSeenAt` and never the run's own time. NULL on every row nothing recorded it for: a post the search's boolean expression discarded (those posts are not recorded, to keep each run's record small) and every post found before the worker release that added these fields (September 2026; not back-filled). PRESENT ON EVERY ROW OF EVERY ANSWER, the older run and prompt fallbacks included: unlike `outcome` and the four numbers it belongs to the POST, not to a run. |
└postedAtTimestamp | integer | no | The same instant in epoch milliseconds, as on GET /api/v1/profile/{username}/posts. Null exactly when `postedAt` is. |
└author | object | no | WHO WROTE THE POST, from the search result's `actor` — the field for telling a company page's post from a person's, and a person's headline usually names their company. ALWAYS AN OBJECT with all three keys: a member is null when the search did not state it, and all three are null on every row nothing recorded them for (an expression discard, or a post found before the September 2026 worker release that added it). PRESENT ON EVERY ROW OF EVERY ANSWER, the older run and prompt fallbacks included: unlike `outcome` and the four numbers it belongs to the POST, not to a run. |
└name | string | yes | The author's display name: a company page's name, or a person's first and last name. |
└url | string | yes | The author's LinkedIn URL as the search gave it (a /company/ or /in/ URL), or, for a person the search gave no URL for, the /in/ URL of their public handle. Null when it gave neither — never built from a member URN, and never for a company page. |
└headline | string | yes | A person author's LinkedIn headline when the search supplied one. Always null for a company page, which has none. |
sweptPosts | object[] | no | EVERY POST THE RUN CONSIDERED, one row each, with what became of it and what it yielded. PRESENT ONLY with `include=swept` — with `include=kept` this key does not appear at all, and a rejected post therefore can never be read out of `keptPosts`. NULL, never [], when the run recorded no per-post list: this key has NO prompt-scoped fallback (`keptPosts` has one; per-post numbers cannot be reconstructed from the verdict table), so the answer is `sweptPosts: null` with `reason`. The rows SUM TO `lastRun`: rows = `postsScanned`, rows whose `outcome` is `kept` or `harvested` = `postsKept`, `harvested` rows = `postsHarvested`, `discardedByExpression` counts only expression discards; AI-rejected rows are also `discarded` but do not increment that counter, and the four counters summed across rows are `engagersSeen`, `engagersDropped`, `engagersDuplicate` and `leadsWritten`. A RUN THAT KEPT NOTHING STILL HAS ROWS HERE: `keptPosts` is [] for it, while this list carries one `discarded` row per post its expression or AI filter rejected — see `unlistedDiscards` for the only case a discard can be counted and not listed. THE `discarded` ROWS ARE WHAT MAKES A NARROW EXPRESSION READABLE: they are the near-misses, each carrying the clause it failed, and before 2026-09-21 a post an expression threw away appeared in no list on any surface and in no count either. |
└urn | string | no | The activity URN, in exactly the form POST /api/v1/post/reactions accepts as `postUrn`. |
└url | string | no | The post's permalink, or null when nothing was stored for it — which is every `scanned` and every `kept` row, and is why /post/reactions answers 404 for those URNs. On a `discarded` row it is the post's LinkedIn permalink AS THE SEARCH RETURNED IT, recorded by the run because nothing was stored — open it to check the `reason`; /post/reactions still answers 404 for that URN. When that link was not stored — the run's per-post record had to be stored without share links, or the row predates them — it is the /feed/update/ permalink built from the post's URN, so a discarded row's `url` is never null. |
└outcome | "scanned" | "kept" | "harvested" | "discarded" | no | What became of this post. `scanned` — the run stopped before a completed AI decision, or the filter gave no verdict; a run with no AI filter never produces this value, because it keeps everything it scans. `kept` — kept and NEVER REACHED: a cap stopped the loop, or the capture itself failed, so no post row was stored, `url` is null and /post/reactions answers 404. `harvested` — captured, and the ONLY outcome that can carry non-zero counts below. `discarded` — a boolean expression rejected it before AI, or a completed AI filter explicitly rejected it; `reason` identifies the clause or says `AI filter rejected this post` (with the provider and model when recorded), and nothing was stored for it, so `url` is the permalink the search returned and /post/reactions answers 404. |
└reason | string | no | WHY A `discarded` ROW WAS DISCARDED, in the few words that name the failing expression clause (`missing phrase "sales ops"`, `missing hiring`, `contains recruiter`) or the completed AI verdict (`AI filter rejected this post`, followed by the provider and model that judged it when recorded: `AI filter rejected this post (openai, gpt-4o-mini)` — match on the prefix). An expression reason is one of three shapes: `missing phrase "<phrase>"`, `missing <term>` or `contains <term>`, each naming the term as it was typed in the expression. NULL — not absent — on every other outcome, so a caller never has to read an absent key as "not discarded". ALWAYS PRESENT on a run-scoped answer. It names a TERM and never quotes the post, and for a multi-branch expression it is the FIRST `OR` branch's first failure: the branches are alternatives, the post failed every one of them, and the first is the one the reader wrote first. |
└engagersSeen | integer | no | Engagers this post's capture received. ALWAYS PRESENT on a run-scoped answer, 0 included — a measured 0 on a `harvested` row is a QUIET post, as against a `harvested` row with people seen and no leads, which is a lossy capture. 0 on every `scanned` and `kept` row, which were never captured. |
└engagersDropped | integer | no | Engagers this post yielded that could not become a lead — no identity at all, or an organisation page. ALWAYS PRESENT on a run-scoped answer, 0 included. |
└engagersDuplicate | integer | no | Rows this post produced that collided with a lead the team already held — already captured, so free rather than lost. ALWAYS PRESENT on a run-scoped answer, 0 included. |
└leadsWritten | integer | no | Lead rows this post actually inserted, equal to the credits it spent. THIS IS THE COLUMN THAT ANSWERS "my run harvested 25 posts and wrote 21 leads; from which of them?" — sort on it, then read `engagersSeen` on the zero-lead rows. ALWAYS PRESENT on a run-scoped answer, 0 included. |
└totalReactionCount | integer | no | Reactions on the post AS THE KEYWORD SEARCH REPORTED THEM when the run found it (the result's `numReactions`) — a count, not the people, under the same name and type GET /api/v1/profile/{username}/posts uses. Refreshed only when a later run finds the post again (a kept post the budget never reached is re-found); never a later live reading. NULL, not 0, when the search stated no count; NULL on every row nothing recorded it for: a post the search's boolean expression discarded (those posts are not recorded, to keep each run's record small) and every post found before the worker release that added these fields (September 2026; not back-filled). ALWAYS PRESENT on a run-scoped answer. |
└commentsCount | integer | no | Comments on the post as the keyword search reported them when the run found it (`numComments`) — a count, not the commenters. 0 is a stated zero, a post nobody had commented on; NULL is a post the search said nothing about, and is never read as 0. NULL on every row nothing recorded it for: a post the search's boolean expression discarded (those posts are not recorded, to keep each run's record small) and every post found before the worker release that added these fields (September 2026; not back-filled). ALWAYS PRESENT on a run-scoped answer. |
└contentType | "VIDEO" | "IMAGE" | "JOB" | "LIVE_VIDEO" | "DOCUMENT" | "COLLABORATIVE_ARTICLE" | null | no | The post's kind, read from the search result's media exactly as GET /api/v1/profile/{username}/posts reads it — the same vocabulary as the keyword search's own contentType filter. NULL for a text post and for media with no value in this vocabulary (an article, a poll); NULL on every row nothing recorded it for: a post the search's boolean expression discarded (those posts are not recorded, to keep each run's record small) and every post found before the worker release that added these fields (September 2026; not back-filled). ALWAYS PRESENT on a run-scoped answer. |
└postedAt | string <date-time> | no | When the post was created, ISO 8601. ⚠ THE KEYWORD SEARCH RESULT HAS NO TIME FIELD: this is the instant the post's activity URN encodes (a LinkedIn activity id carries its creation time, in milliseconds, in its top 41 bits), recorded by the run. Never `firstSeenAt` and never the run's own time. NULL on every row nothing recorded it for: a post the search's boolean expression discarded (those posts are not recorded, to keep each run's record small) and every post found before the worker release that added these fields (September 2026; not back-filled). ALWAYS PRESENT on a run-scoped answer. |
└postedAtTimestamp | integer | no | The same instant in epoch milliseconds, as on GET /api/v1/profile/{username}/posts. Null exactly when `postedAt` is. |
└author | object | no | WHO WROTE THE POST, from the search result's `actor` — the field for telling a company page's post from a person's, and a person's headline usually names their company. ALWAYS AN OBJECT with all three keys: a member is null when the search did not state it, and all three are null on every row nothing recorded them for (an expression discard, or a post found before the September 2026 worker release that added it). ALWAYS PRESENT on a run-scoped answer. |
└name | string | yes | The author's display name: a company page's name, or a person's first and last name. |
└url | string | yes | The author's LinkedIn URL as the search gave it (a /company/ or /in/ URL), or, for a person the search gave no URL for, the /in/ URL of their public handle. Null when it gave neither — never built from a member URN, and never for a company page. |
└headline | string | yes | A person author's LinkedIn headline when the search supplied one. Always null for a company page, which has none. |
unlistedDiscards | object | no | PRESENT ONLY with `include=swept`, and only when the run's `lastRun.discardedByExpression` counts posts that `sweptPosts` has no `discarded` row for (AI rejections are not part of that counter and are not compared). ABSENT whenever the list is complete, which is every run recorded since 2026-09-21 that fits the list's 2000-row bound. It exists because a list without these rows, served bare, is indistinguishable from a run that never saw those posts. |
└count | integer | no | How many of the posts `discardedByExpression` counts are not listed. |
└reason | string | no | Which of the two causes applies: the run was recorded before discarded posts were listed (a worker before 2026-09-21, which also counted only the survivors in `postsScanned`), or the list reached its 2000-row bound, which keeps the posts the run kept first and drops the tail of its discards. The missing URNs and reasons were never stored and are not reconstructed. |
reason | string | no | Why the list is null — present only when `keptPosts` (or, with `include=swept`, `sweptPosts`) is null. |
| Status | Meaning | Example error |
|---|---|---|
| 400 | ||
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 404 | No keyword search with that id on this team. |
List captured leads
/api/v1/leadsList and filter the engagers already captured and enriched for your tracked profiles — no re-sweep of posts required. Read-only and synchronous, so it draws on the higher read rate limit. Returns enriched leads only, and (unless you scope to one tracked source with profileId/username — the source that captured the leads, not a lead's own profile) only profiles whose first sync has finished — the same set the dashboard and CSV export show. Ordered newest-engagement-first. Page with limit/offset and read `total`/`hasMore`; for incremental pulls, filter on `since`/`until` (both compare against the lead's createdAt). Unknown query parameters are rejected with a 400, so a typo or a removed filter never silently returns an unfiltered page. GRAIN: this endpoint returns one row per ENGAGEMENT (each like or comment is its own row), so a repeat engager appears once per interaction and `total` counts ENGAGEMENTS, NOT unique people. Use GET /api/v1/engagers (one row per PERSON, with engagementCount) when you need a count of people. WHAT A PENDING LEAD IS. Capture and enrichment are two steps: capture records the engager (name, LinkedIn username and URL, avatar), enrichment then adds job title, company and country. This endpoint returns ENRICHED leads only, so a captured-but-unenriched lead is absent from `data` and `total` entirely — it is not returned with blank fields. `pendingEnrichment` counts exactly those absent leads. Enrichment is GATED: it only runs for a team whose subscription is active (or a live trial) and which has enriching credits. A team that is cancelled, blocked or out of credits accumulates pending leads INDEFINITELY — measured in production, one cancelled team holds 54,947 leads that have never been attempted, the oldest waiting 51 days. So a large pendingEnrichment means enrichment is gated for that team, NOT that the queue is stuck. Check GET /api/v1/credits. SCOPING TO A KIND OF SOURCE. `?sourceKind=keyword|post|profile` narrows the all-sources view to the leads captured by one kind of source — the way to answer "how many leads have my keyword searches produced" in a single call. It spans untracked keyword searches, whose leads are deliberately kept, which is why it can return MORE than the sum over GET /api/v1/sources (that lists only active sources). UNTRACKED SOURCES. A source you untracked is out of scope here on BOTH paths: absent from the all-sources view, and a 404 when named with profileId/username — the same rule the dashboard applies, so this endpoint cannot answer with leads the product told you were gone. KEYWORD searches are the deliberate exception: untracking one keeps its leads, and they stay readable both by id and under `sourceKind=keyword`. COMPANY FIELDS: companyName (also `company`), companyUrl (company website URL), companyDomain (website hostname), companyLinkedinUrl (LinkedIn company page), companyDescription, companyIndustry, companyLocation (headquarters), companyEmployeeCount, companyStaffRange and companyEnrichedAt describe the current employer. companyUrl is the company's own website, from the website field of its company record, and never a LinkedIn URL; companyLinkedinUrl is its LinkedIn company page. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits. companyStaffRange is the LinkedIn size bucket and companyEmployeeCount is the reported total, so the two can disagree. companyEnrichedAt is null until the company has been resolved; after that, a null company field means the company record has no value for it. Resolving a company costs one provider lookup, not a Cornersight credit, and each company record is cached for 6 months. companyDescription and companyLocation (headquarters) come from the company record, resolved once per company and cached for 6 months, at no enriching-credit cost.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
limit | integer | no | Page size, 1-100 (default 50). Outside the range is a 400, not a clamp. |
offset | integer | no | Number of leads to skip. 0 or more; a negative offset is a 400. An offset past the last row is an empty page, not an error. |
profileId | string <uuid> | no | Scope to one tracked SOURCE by its Cornersight id. This is the person or company page you MONITOR that captured these leads (the id from GET /api/v1/sources, or returned when you track it) — NOT the lead's own LinkedIn profile. 404 if it is not one of your tracked sources. Mutually exclusive with username. |
username | string | no | Scope to one tracked SOURCE by its LinkedIn username — the person or company you monitor, not the lead's username. 404 if not tracked. Mutually exclusive with profileId. |
engagementType | "Like" | "Comment" | "Author" | no | Filter by engagement kind (case-insensitive): Like, Comment, or Author (a keyword search's post author, captured when the search has capturePostAuthors on). |
isIcp | boolean | no | Only leads matching (true) or not matching (false) the profile's ICP rules. |
webhookStatus | "pending" | "sent" | "failed" | "no_webhook" | no | Filter by webhook delivery state. |
includeInactive | boolean | no | When true, sources the team has UNTRACKED are back in scope. Untracking is a SOFT DELETE on every kind — the row is deactivated and every lead it captured is kept, because deleting cascades and is unrecoverable — and until this flag existed a person, company page or tracked post's kept leads could not be read back on any parameter: absent from the all-sources view and a 404 when named. This is the leads half of GET /api/v1/sources?includeInactive=true, spelled the same way and opt-in for the same reason. DEFAULT FALSE, and the default is unchanged: a caller who does not ask sees exactly what they saw before, which is what the dashboard's untrack dialog promises. It relaxes BOTH paths — the all-sources view and profileId/username, which would otherwise 404 on an id this flag had just widened the view to include. A keyword search's leads are readable either way; that exception is the KIND's, not this flag's. ⚠️ READING IS NOT ACTING: an untracked source still cannot be pushed, and its webhook and ICP configuration still answer 404 — delivering a deleted source's leads to a webhook is the failure that rule exists for. Rejected with a 400 unless it is exactly true or false. |
includeSyncing | boolean | no | When true, the all-profiles view (no profileId/username) also includes profiles currently re-syncing, whose already-enriched leads are otherwise hidden until the sync finishes. leads_ready is flipped false at the start of every sync (including the daily one). Ignored when profileId or username is set. |
sourceKind | "all" | "profile" | "post" | "keyword" | no | Scope the all-sources view to one KIND of capturing source. A lead has no kind of its own — it inherits the tracked source that captured it — so this narrows which SOURCES are in scope, not which leads. `keyword` is keyword searches, `post` is tracked posts, `profile` is person AND company pages (both are one kind here), and `all` is the default, which adds no narrowing at all. THIS IS HOW YOU COUNT ONE KIND'S LEADS: `?sourceKind=keyword&limit=1`, then read `total`. Summing per-source counts from GET /api/v1/sources cannot answer it, because that lists only ACTIVE sources while an untracked keyword search keeps its leads and stays in this scope. On the team that reported this the two came to 402 and 96. The vocabulary is deliberately not this API's `type`: `person` and `company` are rejected with a 400 naming `profile`, rather than accepted as aliases that would silently widen to every profile source. Cannot be combined with profileId/trackedProfile/source/username — a named source is already one kind, and a filter that would not be applied is a 400 here rather than a page that does not mean what was asked. |
since | string <date-time> | no | Only leads stored at/after this ISO 8601 timestamp. |
until | string <date-time> | no | Only leads stored at/before this ISO 8601 timestamp. |
name | string | no | Case-insensitive substring match on the lead's name. |
jobTitle | string | no | Case-insensitive substring match on job title. |
company | string | no | Case-insensitive substring match on company name. |
companyDomain | string | no | Case-insensitive substring match on company domain. |
companyIndustry | string | no | Case-insensitive substring match on the cached employer industry. Unknown industries do not match; retrieving the industry costs the customer no enriching credits. |
country | string | no | Case-insensitive substring match on country. |
Responses
200 — A page of captured leads.
| Field | Type | Required | Description |
|---|---|---|---|
data | object[] | no | |
└id | string <uuid> | no | |
└profileId | string <uuid> | no | Tracked profile whose post this engagement came from. |
└name | string | no | |
└linkedinUsername | string | no | The public vanity handle, or null. NEVER a member URN — presentLeadIdentity splits the stored key and puts the URN in `linkedinUrn` (worker/src/lead-identity.ts). Capture keys a handle-less engager's lead on their member URN, but this endpoint returns ENRICHED leads only and enrichment resolves the public handle from that URN first, so a lead you can see almost always carries a real handle. Measured 2026-09-08: of the 3,265 enriched leads captured since URN-keyed capture shipped (2026-09-07), ZERO kept the URN. The null population is real but small and historical — 6,651 leads, 2.4% of the 277,054 enriched fleet-wide, every one captured before 2026-09-07 — and concentrated: 71 sources hold them and one is 70% URN-keyed. A NULL THEREFORE MEANS ENRICHMENT COULD NOT RESOLVE A HANDLE, not that the engager had none — fall back to `linkedinUrn` rather than discarding the row. |
└linkedinUrn | string | no | The lead's LinkedIn member URN, when one is known. Populated at capture or learned during enrichment. It is the STABLE identity: a handle-less engager's lead is keyed on this at capture, and enrichment normally replaces that key with the resolved handle — `linkedinUsername` stays null only where it could not, which is the one case a consumer must fall back to this field for. It is also what GET /api/v1/engagers groups on, which is why that endpoint reports one row per person across both spellings. |
└linkedinUrl | string | no | Built from whatever identity keys the lead — normally the resolved vanity handle, so normally a working profile URL. It reads https://www.linkedin.com/in/ACoAAB... , which does NOT resolve publicly, only on the ~2.4% of enriched leads whose handle enrichment could not resolve. The test is `linkedinUsername === null`, NOT a prefix check on `linkedinUsername`, which is never a URN here. Treat it as canonical for CRM matching, dedup keys and click-through only when `linkedinUsername` is non-null. |
└avatarUrl | string | no | |
└jobTitle | string | no | |
└company | string | no | |
└companyName | string | no | Company name; same value as company. |
└companyDomain | string | no | Company website hostname, without a scheme or path. |
└companyUrl | string | no | companyUrl is the company's own website, from the website field of its company record, and never a LinkedIn URL; companyLinkedinUrl is its LinkedIn company page. Null when the company record has no website. companyDomain is the same website's hostname. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits. |
└companyDescription | string | no | Company description (its tagline when it has no description). companyDescription and companyLocation (headquarters) come from the company record, resolved once per company and cached for 6 months, at no enriching-credit cost. |
└companyLocation | string | no | Company headquarters location, as City, Region, Country. companyDescription and companyLocation (headquarters) come from the company record, resolved once per company and cached for 6 months, at no enriching-credit cost. |
└companyLinkedinUrl | string | no | The employer's LinkedIn company page, read from the person's current position (the company record fills it only when that is missing), or null. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits. |
└companyIndustry | string | no | The employer's industry, or null. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits. |
└companyEmployeeCount | integer | no | The employer's reported total employee count, or null (never an invented zero). Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits. companyStaffRange is the LinkedIn size bucket and companyEmployeeCount is the reported total, so the two can disagree. |
└companyStaffRange | string | no | The employer's LinkedIn size bucket, for example "51-200", or null. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits. companyStaffRange is the LinkedIn size bucket and companyEmployeeCount is the reported total, so the two can disagree. |
└companyEnrichedAt | string <date-time> | no | When this lead's company record was resolved (ISO 8601), or null. companyEnrichedAt is null until the company has been resolved; after that, a null company field means the company record has no value for it. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits. |
└country | string | no | |
└engagementType | "Like" | "Comment" | "Author" | no | How the person engaged with the post: `Like`, `Comment`, or `Author` — the person who WROTE a post a keyword search kept, captured when that search has `capturePostAuthors` on. An Author lead is charged like an engager (one credit per new person for the search, repeats free), and the same person can hold an Author row and a Like or Comment row on one post: two rows, one credit. |
└commentText | string | no | Comment body when engagementType is Comment. |
└commentPostedAt | string <date-time> | no | When the COMMENT itself was posted, ISO 8601 - the comment's own time, not the post's (that is postPostedAt). Always present. Null for Likes. On a Comment lead it is the data provider's comment time when one was sent (the fallback provider sends it), otherwise the time the comment's own LinkedIn ID encodes (see COMMENT TIME on POST /api/v1/post/comments) - on every capture path, keyword-search Comment leads included. Null only when neither was available, which can be the case on Comment leads captured before comment times were decoded. Never filled from the post's time. |
└isIcp | boolean | no | Whether the lead matched the profile's ICP filter rules. |
└webhookStatus | "pending" | "sent" | "failed" | "no_webhook" | no | |
└postUrl | string | no | URL of the post that was engaged with. |
└postPostedAt | string <date-time> | no | When the POST this lead engaged with was published - the post's time, not the comment's. On a Comment lead the comment's own time is commentPostedAt. |
└detectedAt | string <date-time> | no | |
└createdAt | string <date-time> | no | When Cornersight stored the lead. Use with since/until for incremental pulls. |
total | integer | no | Exact count of leads matching the filters, ignoring limit/offset. Counts ENGAGEMENTS, not unique people - see the endpoint description. Page with `hasMore` rather than by dividing this number: `hasMore` is derived from the rows actually returned. |
limit | integer | no | |
offset | integer | no | |
hasMore | boolean | no | True when more pages remain after this one. |
pendingEnrichment | integer | no | Leads captured for this scope that are still awaiting enrichment, and are therefore NOT in `data` and NOT counted in `total`. WARNING: it is not "how many returned rows lack company/title" — this endpoint returns enriched leads only, so every row in `data` already has its firmographics. This number is what the response is NOT showing you, which is why `total` alone can look lower than the leads you know were captured. Scope matches the dashboard's counter: team plus the same profile scoping (profileId when given, otherwise your visible sources), and it deliberately ignores engagementType/webhookStatus/isIcp/since/until and the text filters — those read fields a pending lead does not have yet. Always present; 0 means nothing is waiting. Enrichment is GATED: it only runs for a team whose subscription is active (or a live trial) and which has enriching credits. A team that is cancelled, blocked or out of credits accumulates pending leads INDEFINITELY — measured in production, one cancelled team holds 54,947 leads that have never been attempted, the oldest waiting 51 days. So a large pendingEnrichment means enrichment is gated for that team, NOT that the queue is stuck. Check GET /api/v1/credits. |
{
"data": [
{
"id": "6f1b6f2e-1f4a-4c1d-9f2b-2a7c1d3e4f50",
"profileId": "b2c3d4e5-6789-4abc-9def-0123456789ab",
"name": "Dana Reyes",
"linkedinUsername": "danareyes",
"linkedinUrl": "https://www.linkedin.com/in/danareyes/",
"avatarUrl": null,
"jobTitle": "VP Marketing",
"company": "Northwind",
"companyDomain": "northwind.com",
"country": "United States",
"engagementType": "Comment",
"commentText": "This matches what we're seeing.",
"commentPostedAt": null,
"isIcp": true,
"webhookStatus": "sent",
"postUrl": "https://www.linkedin.com/feed/update/urn:li:activity:7300000000000000000/",
"postPostedAt": "2026-07-18T09:12:00Z",
"detectedAt": "2026-07-18T10:02:11Z",
"createdAt": "2026-07-18T10:02:11Z"
}
],
"total": 1284,
"limit": 50,
"offset": 0,
"hasMore": true
}| Status | Meaning | Example error |
|---|---|---|
| 400 | A query parameter was malformed (bad limit/offset/timestamp, unknown enum value, or both profileId and username). | |
| 404 | The requested profileId/username is not a tracked source for this team — which now includes a source you UNTRACKED, since its leads stop being served. Keyword searches are the exception: an untracked search's id still resolves, because its leads are kept. |
List raw (unenriched) leads
/api/v1/leads/rawList the RAW leads of sources in raw mode (`enrichLeads: false`): people captured from engagement exactly as capture recorded them and NEVER enriched. Read-only and synchronous. A raw lead carries who engaged (linkedinUrl, linkedinUrn, name), how (action and commentText), on which post (post.url, post.urn, post.postedAt) and when it was captured (detectedAt) — and no job title, company, country or ICP score, because no enrichment provider is ever called for it. WHERE RAW LEADS COME FROM, AND WHAT THEY COST. `enrichLeads: false` on POST /api/v1/enrich/{profile,company} (with saveTrackedProfile: true), POST /api/v1/post/track, POST /api/v1/keyword/track, PATCH /api/v1/{profile,company}/{username} or PATCH /api/v1/keyword/{id} puts a source in raw mode; GET /api/v1/sources reports it per source as `enrichLeads`. The price is unchanged: ONE CREDIT PER NEW PERSON PER SOURCE, charged when the lead is captured, with repeats free and the same ledger and caps as enrichment. Reading them here charges nothing. Switching the mode needs no `confirmSpend` and applies to leads captured or processed after the switch — leads already enriched stay enriched, raw leads stay raw. ONLY HERE. A raw lead is `enriched = false`, so it never appears in GET /api/v1/leads, GET /api/v1/engagers, the dashboard, CSV exports, webhooks, lead pushes or integrations. This endpoint is how raw leads are delivered. SCOPE AND VISIBILITY ARE GET /api/v1/leads'. With no source named it spans every source the team tracks; a source you UNTRACKED is out of scope (and a 404 when named) unless `includeInactive=true`, except a keyword search, whose leads are kept either way. One source selector at most: `profileId` (also accepted as `trackedProfile` or `source`) or `username`; an unknown source is a 404. There is no readiness gate and no `includeSyncing`: a raw lead is final the moment it is written, so a source's raw leads stay listed while it re-syncs. ORDER AND PAGING. Newest `detectedAt` first, `id` as the tiebreak, so deep offsets are deterministic. `since`/`until` compare against `detectedAt`, so an incremental pull is `since=<the newest detectedAt you already hold>`. Page with limit/offset and follow `hasMore`; `total` is the exact size of the filtered set. Unknown query parameters are a 400, so a typo never returns an unfiltered page. NAMES. Capture stores a person's handle or member id where the provider sent no display name. A raw lead is never enriched, so that fallback would reach you as the name; instead `name` is null whenever the stored value is only an identifier (it matches the handle or the URN, or looks like one: ACoAA… or urn:li:…). An id is never returned as a name — identify the person by `linkedinUrl` / `linkedinUrn`.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
limit | integer | no | Page size, 1-100 (default 50). Outside the range is a 400, not a clamp. |
offset | integer | no | Number of raw leads to skip. 0 or more; a negative offset is a 400. An offset past the last row is an empty page, not an error. |
profileId | string <uuid> | no | Scope to one tracked SOURCE by its Cornersight id — the person, company page, post or keyword search you track that captured these leads (the `id` from GET /api/v1/sources), NOT the lead's own profile. Also accepted as `trackedProfile` or `source`. 404 if it is not one of your tracked sources. Mutually exclusive with username. |
username | string | no | Scope to one tracked SOURCE by its LinkedIn username — the person or company you monitor, not the lead's username. 404 if not tracked. Mutually exclusive with profileId. |
action | "Like" | "Comment" | "Author" | no | Only this kind of engagement (case-insensitive): Like, Comment, or Author (a keyword search's post author, captured when the search has capturePostAuthors on). |
since | string <date-time> | no | Only raw leads detected at/after this ISO 8601 timestamp (compared against detectedAt). |
until | string <date-time> | no | Only raw leads detected at/before this ISO 8601 timestamp (compared against detectedAt). |
includeInactive | boolean | no | When true, sources the team has UNTRACKED are back in scope, on both the all-sources view and a named profileId/username — the same opt-in, with the same meaning, as on GET /api/v1/leads. A keyword search's leads are readable either way. Rejected with a 400 unless it is exactly true or false. |
Responses
200 — A page of raw leads.
| Field | Type | Required | Description |
|---|---|---|---|
data | object[] | yes | |
└id | string <uuid> | yes | The lead's id. |
└sourceId | string <uuid> | yes | The tracked source that captured the lead — the `id` GET /api/v1/sources reports, and the value `profileId` scopes by. |
└linkedinUrl | string | yes | The person's LinkedIn profile URL as captured. For someone captured without a public handle it is built from their member id (https://www.linkedin.com/in/ACoAA…), which does not resolve publicly; `linkedinUrn` then carries that id. |
└linkedinUrn | string | null | yes | The person's LinkedIn member URN (ACoAA…) when one is known, else null. A handle is never reported here, and this id is never reported as a name. |
└name | string | null | yes | The person's name as captured, or null when capture recorded only an identifier — a value equal to the handle or the URN, or one that looks like a member URN (ACoAA… / urn:li:…). An id is never returned as a name. Raw leads are never enriched, so a null stays null. |
└action | "Like" | "Comment" | "Author" | yes | How the person engaged: `Like`, `Comment`, or `Author` — the person who WROTE a post a keyword search kept, captured when that search has `capturePostAuthors` on. |
└commentText | string | null | yes | The comment body when action is Comment, else null. |
└post | object | yes | The post that was engaged with. |
└url | string | yes | The post's LinkedIn URL. |
└urn | string | yes | The post's LinkedIn URN, e.g. urn:li:activity:7300000000000000000. |
└postedAt | string | null <date-time> | yes | When the post was published, or null when it is not known. |
└detectedAt | string <date-time> | yes | When the engagement was captured. Rows are ordered by it (newest first) and since/until compare against it. |
total | integer | yes | Exact count of raw leads matching the filters, ignoring limit/offset. Counts ENGAGEMENTS, not unique people: a person who liked and commented is two rows. Page with `hasMore`, not by dividing this number. |
limit | integer | yes | |
offset | integer | yes | |
hasMore | boolean | yes | True when more pages remain after this one. Derived from the rows actually fetched, not from `total`. |
{
"data": [
{
"id": "6f1b6f2e-1f4a-4c1d-9f2b-2a7c1d3e4f51",
"sourceId": "b2c3d4e5-6789-4abc-9def-0123456789ab",
"linkedinUrl": "https://www.linkedin.com/in/jane-doe",
"linkedinUrn": "ACoAAB1a2b3c4d5e6f7g8h9i0j",
"name": "Jane Doe",
"action": "Comment",
"commentText": "This matches what we're seeing.",
"post": {
"url": "https://www.linkedin.com/feed/update/urn:li:activity:7300000000000000000/",
"urn": "urn:li:activity:7300000000000000000",
"postedAt": "2026-07-17T08:00:00.000Z"
},
"detectedAt": "2026-07-18T10:02:11.000Z"
}
],
"total": 1284,
"limit": 50,
"offset": 0,
"hasMore": true
}| Status | Meaning | Example error |
|---|---|---|
| 400 | A query parameter was malformed or unknown (bad limit/offset/timestamp/action/includeInactive, more than one source selector, or a profileId that is not a UUID). | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The authenticated team does not have an active subscription or an unexpired trial. | Team subscription not active |
| 404 | The requested profileId/username is not a tracked source for this team — including one you untracked, unless includeInactive=true. Keyword searches are the exception: an untracked search's id still resolves. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
List top engagers
/api/v1/engagersAggregate captured leads to one row per PERSON — the repeat-engagement signal GET /api/v1/leads (one row per engagement) can't answer directly. Sorted by engagementCount desc by default; pass orderBy=lastEngagedAt for most-recent-first, and minEngagements to keep only repeat engagers. Scope and gating mirror GET /api/v1/leads exactly (team-scoped, enriched only, leads_ready sources unless includeSyncing=true). Read-only, returns immediately. Paginated: { data, total, limit, offset, hasMore }. IDENTITY: rows are one per PERSON even when the same person was captured under two LinkedIn spellings. ~3% of leads store a member URN ("ACoAAB...") in linkedinUsername instead of a public handle; aggregation groups on the captured member URN where there is one, so a handle capture and a URN capture of the same person merge into a single row with the combined engagementCount, reported under the handle. linkedinUsername can still be a URN for someone only ever seen that way. UNTRACKED SOURCES. A source you untracked is out of scope here on BOTH paths — absent from the all-sources view, and a 404 when named with profileId/username — the same rule GET /api/v1/leads applies, because these are the same leads grouped. "Scope and gating mirror GET /api/v1/leads exactly" was true of readiness and team scope and NOT of this rule: this endpoint resolved its own source set with no status clause, so a deleted profile kept answering with its engagers while /leads 404'd on the same id. KEYWORD searches are the deliberate exception on both endpoints: untracking one keeps its leads, so its engagers stay readable, by id and in the all-sources view. Untracking never erases anything — the source is deactivated and its rows are retained; what deleting changes is what is SERVED. Use GET /api/v1/sources?includeInactive=true to enumerate what you used to track. COMPANY FIELDS: companyName (also `company`), companyUrl (company website URL), companyDomain (website hostname), companyLinkedinUrl (LinkedIn company page), companyDescription, companyIndustry, companyLocation (headquarters), companyEmployeeCount, companyStaffRange and companyEnrichedAt describe the current employer. companyUrl is the company's own website, from the website field of its company record, and never a LinkedIn URL; companyLinkedinUrl is its LinkedIn company page. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits. companyStaffRange is the LinkedIn size bucket and companyEmployeeCount is the reported total, so the two can disagree. companyEnrichedAt is null until the company has been resolved; after that, a null company field means the company record has no value for it. Resolving a company costs one provider lookup, not a Cornersight credit, and each company record is cached for 6 months. companyDescription and companyLocation (headquarters) come from the company record, resolved once per company and cached for 6 months, at no enriching-credit cost.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
limit | integer | no | Page size, 1-100 (default 50). Outside the range is a 400, not a clamp. |
offset | integer | no | Engagers to skip for paging (default 0). 0 or more; a negative offset is a 400. An offset at or past the last row is an empty page, not an error, and it still carries the collection's `total` — walking one page too far is ordinary paging and must not read as "this collection is empty". |
minEngagements | integer | no | Only engagers with at least this many total engagements (default 1). Use 2+ for repeat engagers; 0 means no minimum. A negative value is a 400. |
orderBy | "engagementCount" | "lastEngagedAt" | no | Sort key. |
profileId | string | no | Scope to one tracked source id (aliases: trackedProfile, source). Mutually exclusive with username. |
username | string | no | Scope to one tracked source by LinkedIn username. Mutually exclusive with profileId. |
engagementType | "Like" | "Comment" | "Author" | no | Count only this engagement kind: Like, Comment, or Author (a keyword search's post author). Author rows count toward engagementCount but not likeCount or commentCount. |
isIcp | boolean | no | Only ICP-matching engagers. |
since | string <date-time> | no | Only engagements at/after this ISO 8601 timestamp. |
until | string <date-time> | no | Only engagements at/before this ISO 8601 timestamp. |
includeInactive | boolean | no | When true, sources the team has UNTRACKED are back in scope. Untracking is a SOFT DELETE on every kind — the row is deactivated and every lead it captured is kept, because deleting cascades and is unrecoverable — and until this flag existed a person, company page or tracked post's kept leads could not be read back on any parameter: absent from the all-sources view and a 404 when named. This is the leads half of GET /api/v1/sources?includeInactive=true, spelled the same way and opt-in for the same reason. DEFAULT FALSE, and the default is unchanged: a caller who does not ask sees exactly what they saw before, which is what the dashboard's untrack dialog promises. It relaxes BOTH paths — the all-sources view and profileId/username, which would otherwise 404 on an id this flag had just widened the view to include. A keyword search's leads are readable either way; that exception is the KIND's, not this flag's. ⚠️ READING IS NOT ACTING: an untracked source still cannot be pushed, and its webhook and ICP configuration still answer 404 — delivering a deleted source's leads to a webhook is the failure that rule exists for. Rejected with a 400 unless it is exactly true or false. |
includeSyncing | boolean | no | Include sources currently re-syncing (default false). Ignored when profileId/username is set. |
Responses
200 — A page of engagers, highest-intent first.
| Field | Type | Required | Description |
|---|---|---|---|
data | object[] | no | |
└linkedinUsername | string | no | The engager's LinkedIn handle, as stored on the lead rows behind this aggregate. THIS PAYLOAD IS NOT THE SAME SHAPE AS A LEAD: it is not passed through the identity presenter that GET /api/v1/leads uses, and it carries no `linkedinUrn` field. So for a person whose stored identity key is a member URN — LinkedIn served no public handle and enrichment never resolved one — THIS FIELD IS THAT URN (`ACoAAB1_DY0B-SeJU30N`), and `linkedinUrl` is built from it, so it does not resolve as a public profile. Test for the `ACo` prefix before using the value as a vanity handle: CRM matching, dedup and display all silently miss on it. The same person on GET /api/v1/leads is presented with the two kinds of identity separated — `linkedinUsername: null` and the URN in `linkedinUrn` — so read leads rather than engagers when you need to tell a handle from an identifier. |
└name | string | no | |
└linkedinUrl | string | no | |
└avatarUrl | string | no | |
└jobTitle | string | no | |
└company | string | no | |
└companyName | string | no | Company name; same value as company. |
└companyDomain | string | no | Company website hostname, without a scheme or path. |
└companyUrl | string | no | companyUrl is the company's own website, from the website field of its company record, and never a LinkedIn URL; companyLinkedinUrl is its LinkedIn company page. Null when the company record has no website. companyDomain is the same website's hostname. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits. |
└companyDescription | string | no | Company description (its tagline when it has no description). companyDescription and companyLocation (headquarters) come from the company record, resolved once per company and cached for 6 months, at no enriching-credit cost. |
└companyLocation | string | no | Company headquarters location, as City, Region, Country. companyDescription and companyLocation (headquarters) come from the company record, resolved once per company and cached for 6 months, at no enriching-credit cost. |
└companyLinkedinUrl | string | no | The employer's LinkedIn company page, read from the person's current position (the company record fills it only when that is missing), or null. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits. |
└companyIndustry | string | no | The employer's industry, or null. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits. |
└companyEmployeeCount | integer | no | The employer's reported total employee count, or null (never an invented zero). Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits. companyStaffRange is the LinkedIn size bucket and companyEmployeeCount is the reported total, so the two can disagree. |
└companyStaffRange | string | no | The employer's LinkedIn size bucket, for example "51-200", or null. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits. companyStaffRange is the LinkedIn size bucket and companyEmployeeCount is the reported total, so the two can disagree. |
└companyEnrichedAt | string <date-time> | no | The newest company-record resolution time across this person's leads (ISO 8601), or null. companyEnrichedAt is null until the company has been resolved; after that, a null company field means the company record has no value for it. Company fields come from the company record, which Cornersight resolves once per company and caches for every lead at that company. They cost no enriching credits. |
└country | string | no | |
└isIcp | boolean | no | |
└engagementCount | integer | no | Total engagement rows for this person on the team's tracked content: likes, comments and, from keyword searches with capturePostAuthors on, Author rows for posts they wrote. likeCount + commentCount is the engagement subset, so an author-only person has engagementCount 1 and both of those 0. |
└likeCount | integer | no | |
└commentCount | integer | no | |
└postCount | integer | no | Distinct posts engaged with. |
└firstEngagedAt | string <date-time> | no | |
└lastEngagedAt | string <date-time> | no | |
total | integer | no | Distinct engagers matching the filters, counted over the whole filtered set and INDEPENDENTLY of the page requested — so it is the same number on every page, including an empty one past the end. |
limit | integer | no | |
offset | integer | no | |
hasMore | boolean | no |
| Status | Meaning | Example error |
|---|---|---|
| 400 | Invalid query parameter. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The authenticated team does not have an active subscription or an unexpired trial. | Team subscription not active |
| 404 | Scoped source not found. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
Discover
Start a Discover run
/api/v1/discoverStart a ONE-OFF Discover Influencers run: the people who post about these keywords in the past month and average at least `minEngagement` likes + comments per matching post, at most `maxInfluencers` of them, optionally only in `countries`. Synchronous: answers 201 with the run (`state: "running"`) and the worker runs it within moments; most runs finish within a few minutes. Poll GET /api/v1/discover/{id} for its influencers. SPEND CONFIRMATION. The run costs 1 credit per influencer added, so up to `maxInfluencers` credits, once. Without `"confirmSpend": true` the request is refused with 409 `spend_confirmation_required` carrying `estimatedCredits` and NOTHING is created: show that figure to the person, then re-send the identical request with `"confirmSpend": true`. `confirmSpend: false` is also a 409; a non-boolean is a 400. The keywords are compiled into one OR search exactly as the dashboard compiles them, and the run is the same run the dashboard's Find influencers button creates: past month, sorted by relevance, people only, one credit per person added. See the Discover tag for how engagement, the maximum and the country filter work. FREE TRIAL. A trial has 2 influencer searches of up to 100 people each: maxInfluencers over 100 is a 400 and a third search is a 403, both with code `trial_profile_limit`, before any spend question. The agent's own people search does not use them.
Authenticated with the X-API-Key header.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
keywords | string[] | yes | 1-10 keywords. A post matching ANY of them counts (no AND/NOT); each is a separate LinkedIn search. No quotation marks or brackets; the same keyword twice (ignoring case) is a 400. |
minEngagement | integer | yes | The minimum AVERAGE likes + comments per matching post a person needs to be added. |
maxInfluencers | integer | yes | The most people this run adds, highest averages first. Also the most credits it can spend (1 per person). |
countries | string[] | no | Optional. Only people whose LinkedIn profile is in one of these countries are added. Short forms work (UK, USA, UAE); an entry with no letters is a 400. Omit for any country. |
confirmSpend | boolean | no | Authorises up to maxInfluencers credits, once. Without it the request is a 409 spend_confirmation_required and nothing is created. |
Request examples
{
"keywords": [
"claude code",
"ai agents"
],
"minEngagement": 50,
"maxInfluencers": 25,
"countries": [
"UK"
]
}{
"keywords": [
"claude code",
"ai agents"
],
"minEngagement": 50,
"maxInfluencers": 25,
"countries": [
"UK"
],
"confirmSpend": true
}Responses
201 — The run was created and queued. Countries come back cleaned ("UK" is "United Kingdom").
| Field | Type | Required | Description |
|---|---|---|---|
id | string <uuid> | yes | The run's id. |
keywords | string[] | yes | The topic: the keywords searched. |
expression | string | yes | The keywords as the one OR search they compile to. |
minEngagement | integer | yes | The minimum average likes + comments per matching post. |
maxInfluencers | integer | yes | The most people the run may add. |
countries | string[] | yes | The countries the run is limited to, as saved. Empty = any country. |
datePosted | "PAST_MONTH" | yes | The window: always the past month. |
state | "running" | "done" | "failed" | yes | running until the run finishes; failed if it stopped on an error; done otherwise (with found 0, the dashboard shows "No results"). |
candidates | integer | null | yes | Distinct people (not company pages) among the posts the run read. Null until the run finishes. |
qualified | integer | null | yes | How many of them averaged at least minEngagement (and, with countries, were in one). Null until the run finishes. More than `found` means the run stopped at maxInfluencers. |
found | integer | yes | How many influencers the run added, and charged 1 credit each for. |
countryChecked | integer | null | yes | With countries: how many qualifying people were looked up. Null until the run finishes. |
countryMatched | integer | null | yes | With countries: how many of them were in one of the countries. Null until the run finishes. |
createdAt | string <date-time> | yes | |
finishedAt | string | null <date-time> | yes | When the run finished. Null while running. |
stoppedBy | string | null | yes | Why the run ended, as keyword runs report it (e.g. exhausted, credit_cap, credits, error). |
reason | string | null | yes | A sentence about how the run ended, when there is one. |
{
"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
}| Status | Meaning | Example error |
|---|---|---|
| 400 | The body is invalid, and nothing was created. One sentence names the problem: keywords missing, not an array of strings, empty, duplicated, more than 10, or holding a quotation mark or bracket; minEngagement or maxInfluencers missing or out of range; countries not an array, more than 20, or an entry with no letters ("\"123\" is not a country."); confirmSpend not a boolean. An unknown field is a 400 with `code: "unknown_field"` naming it. On a free trial, maxInfluencers over 100 is a 400 with `code: "trial_profile_limit"`. | keywords must hold 1-10 terms; this has 11. |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The authenticated team does not have an active subscription or an unexpired trial. | Team subscription not active |
| 409 | Code `spend_confirmation_required`: `confirmSpend` was absent or false. NOTHING WAS CREATED. `estimatedCredits` is the most the run can spend (= maxInfluencers, 1 credit per person added, once). Show it to the person and re-send the identical body with `"confirmSpend": true`. | 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. |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 500 | Unexpected server error — the request failed on our side (e.g. a database error), not because of the request itself. On a WRITE endpoint some work may already have been applied before the failure, so prefer re-reading state over blindly retrying, or you can end up with duplicates. | Failed to list leads |
List Discover runs
/api/v1/discoverThis team's Discover runs, newest first: the 50 most recent, the same list as Your Searches on the dashboard's Discover Influencers page. Deleted runs are not listed. Synchronous and free. `candidates`, `qualified`, `countryChecked` and `countryMatched` are null until a run finishes; `found` is how many influencers it added (and charged for). When `qualified` is greater than `found`, the run stopped at its `maxInfluencers` limit.
Authenticated with the X-API-Key header.
Responses
200 — The runs.
| Field | Type | Required | Description |
|---|---|---|---|
runs | object[] | yes | |
└id | string <uuid> | yes | The run's id. |
└keywords | string[] | yes | The topic: the keywords searched. |
└expression | string | yes | The keywords as the one OR search they compile to. |
└minEngagement | integer | yes | The minimum average likes + comments per matching post. |
└maxInfluencers | integer | yes | The most people the run may add. |
└countries | string[] | yes | The countries the run is limited to, as saved. Empty = any country. |
└datePosted | "PAST_MONTH" | yes | The window: always the past month. |
└state | "running" | "done" | "failed" | yes | running until the run finishes; failed if it stopped on an error; done otherwise (with found 0, the dashboard shows "No results"). |
└candidates | integer | null | yes | Distinct people (not company pages) among the posts the run read. Null until the run finishes. |
└qualified | integer | null | yes | How many of them averaged at least minEngagement (and, with countries, were in one). Null until the run finishes. More than `found` means the run stopped at maxInfluencers. |
└found | integer | yes | How many influencers the run added, and charged 1 credit each for. |
└countryChecked | integer | null | yes | With countries: how many qualifying people were looked up. Null until the run finishes. |
└countryMatched | integer | null | yes | With countries: how many of them were in one of the countries. Null until the run finishes. |
└createdAt | string <date-time> | yes | |
└finishedAt | string | null <date-time> | yes | When the run finished. Null while running. |
└stoppedBy | string | null | yes | Why the run ended, as keyword runs report it (e.g. exhausted, credit_cap, credits, error). |
└reason | string | null | yes | A sentence about how the run ended, when there is one. |
total | integer | yes | How many runs are in `runs`. |
{
"runs": [
{
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"keywords": [
"claude code",
"ai agents"
],
"expression": "\"claude code\" OR \"ai agents\"",
"minEngagement": 50,
"maxInfluencers": 25,
"countries": [
"United Kingdom"
],
"datePosted": "PAST_MONTH",
"state": "running",
"candidates": null,
"qualified": null,
"found": 0,
"countryChecked": null,
"countryMatched": null,
"createdAt": "2026-10-05T12:00:00.000Z",
"finishedAt": null,
"stoppedBy": null,
"reason": null
},
{
"id": "1b4e28ba-2fa1-41d2-883f-0016d3cca427",
"keywords": [
"claude code",
"ai agents"
],
"expression": "\"claude code\" OR \"ai agents\"",
"minEngagement": 50,
"maxInfluencers": 25,
"countries": [
"United Kingdom"
],
"datePosted": "PAST_MONTH",
"state": "done",
"candidates": 412,
"qualified": 31,
"found": 25,
"countryChecked": 125,
"countryMatched": 25,
"createdAt": "2026-10-04T09:30:00.000Z",
"finishedAt": "2026-10-04T09:34:12.000Z",
"stoppedBy": "exhausted",
"reason": null
}
],
"total": 2
}| Status | Meaning | Example error |
|---|---|---|
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The authenticated team does not have an active subscription or an unexpired trial. | Team subscription not active |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 502 | The runs could not be read. Retry. |
Get a Discover run and its influencers
/api/v1/discover/{id}One Discover run and the influencers it added, highest average first: the same people Influencer Leads shows for that search. Synchronous and free. `jobTitle`, `company` and `country` come from each person's Author lead once enrichment has filled them, shortly after capture, and are null until then. `highestEngagement` is likes + comments on the person's best matching post (the post they were captured from); until that post is stored it falls back to their average. `topPostText` is null when the post's text was not stored.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | string <uuid> | yes | The Discover run's id: the `id` POST /api/v1/discover returned, or one from GET /api/v1/discover. |
Responses
200 — The run and its influencers.
| Field | Type | Required | Description |
|---|---|---|---|
run | object | yes | |
└id | string <uuid> | yes | The run's id. |
└keywords | string[] | yes | The topic: the keywords searched. |
└expression | string | yes | The keywords as the one OR search they compile to. |
└minEngagement | integer | yes | The minimum average likes + comments per matching post. |
└maxInfluencers | integer | yes | The most people the run may add. |
└countries | string[] | yes | The countries the run is limited to, as saved. Empty = any country. |
└datePosted | "PAST_MONTH" | yes | The window: always the past month. |
└state | "running" | "done" | "failed" | yes | running until the run finishes; failed if it stopped on an error; done otherwise (with found 0, the dashboard shows "No results"). |
└candidates | integer | null | yes | Distinct people (not company pages) among the posts the run read. Null until the run finishes. |
└qualified | integer | null | yes | How many of them averaged at least minEngagement (and, with countries, were in one). Null until the run finishes. More than `found` means the run stopped at maxInfluencers. |
└found | integer | yes | How many influencers the run added, and charged 1 credit each for. |
└countryChecked | integer | null | yes | With countries: how many qualifying people were looked up. Null until the run finishes. |
└countryMatched | integer | null | yes | With countries: how many of them were in one of the countries. Null until the run finishes. |
└createdAt | string <date-time> | yes | |
└finishedAt | string | null <date-time> | yes | When the run finished. Null while running. |
└stoppedBy | string | null | yes | Why the run ended, as keyword runs report it (e.g. exhausted, credit_cap, credits, error). |
└reason | string | null | yes | A sentence about how the run ended, when there is one. |
influencers | object[] | yes | Highest average first. |
└name | string | yes | |
└linkedinUrl | string | null | yes | Their LinkedIn profile URL. |
└avatarUrl | string | null | yes | |
└jobTitle | string | null | yes | From enrichment, shortly after capture. Null until then. |
└company | string | null | yes | From enrichment, shortly after capture. Null until then. |
└country | string | null | yes | From enrichment, shortly after capture. Null until then. |
└highestEngagement | number | yes | Likes + comments on their best matching post (the one they were captured from). The average until that post is stored. |
└avgEngagement | number | yes | Average likes + comments across their matching posts, to 2 decimals. This is what minEngagement is compared with. |
└postCount | integer | yes | How many of their posts matched. |
└totalEngagement | integer | yes | Likes + comments summed over their matching posts. |
└topPostUrl | string | null | yes | Their best matching post. |
└topPostText | string | null | yes | That post's text, when stored. |
{
"run": {
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"keywords": [
"claude code",
"ai agents"
],
"expression": "\"claude code\" OR \"ai agents\"",
"minEngagement": 50,
"maxInfluencers": 25,
"countries": [
"United Kingdom"
],
"datePosted": "PAST_MONTH",
"state": "done",
"candidates": 412,
"qualified": 31,
"found": 25,
"countryChecked": 125,
"countryMatched": 25,
"createdAt": "2026-10-05T12:00:00.000Z",
"finishedAt": "2026-10-05T12:06:41.000Z",
"stoppedBy": "exhausted",
"reason": null
},
"influencers": [
{
"name": "Jane Doe",
"linkedinUrl": "https://www.linkedin.com/in/jane-doe",
"avatarUrl": "https://media.licdn.com/dms/image/example.jpg",
"jobTitle": "Founder",
"company": "Acme",
"country": "United Kingdom",
"highestEngagement": 412,
"avgEngagement": 268.5,
"postCount": 2,
"totalEngagement": 537,
"topPostUrl": "https://www.linkedin.com/feed/update/urn:li:activity:7381234567890123456/",
"topPostText": "We moved our whole release process onto Claude Code. Here is what changed..."
}
]
}| Status | Meaning | Example error |
|---|---|---|
| 400 | The path segment is not a run id (a UUID). | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The authenticated team does not have an active subscription or an unexpired trial. | Team subscription not active |
| 404 | No active Discover run with that id for this team: it never existed, was deleted, or is a keyword search rather than a Discover run. | Discover run not found |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 500 | Unexpected server error — the request failed on our side (e.g. a database error), not because of the request itself. On a WRITE endpoint some work may already have been applied before the failure, so prefer re-reading state over blindly retrying, or you can end up with duplicates. | Failed to list leads |
Delete a Discover run
/api/v1/discover/{id}Delete a Discover run, as Delete on Your Searches does. A SOFT DELETE: the run is deactivated, so it leaves GET /api/v1/discover and its influencers leave GET /api/v1/discover/{id} and Influencer Leads, but the Author leads it already added stay in your leads (GET /api/v1/leads?engagementType=Author) and no credit is refunded. A run still in progress stops at its next checkpoint. Only a Discover run: a keyword search's id is a 404 here (stop those with DELETE /api/v1/keyword/{id}). Synchronous.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | string <uuid> | yes | The Discover run's id: the `id` POST /api/v1/discover returned, or one from GET /api/v1/discover. |
Responses
200 — The run is deleted.
| Field | Type | Required | Description |
|---|---|---|---|
ok | boolean | yes | |
id | string <uuid> | yes | |
deleted | boolean | yes |
{
"ok": true,
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"deleted": true
}| Status | Meaning | Example error |
|---|---|---|
| 400 | The path segment is not a run id (a UUID). | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The team's subscription is inactive, or this is a trial run: "Trial searches cannot be deleted. Subscribe to a plan to manage them." | |
| 404 | No active Discover run with that id for this team, including one already deleted. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 500 | Unexpected server error — the request failed on our side (e.g. a database error), not because of the request itself. On a WRITE endpoint some work may already have been applied before the failure, so prefer re-reading state over blindly retrying, or you can end up with duplicates. | Failed to list leads |
Posts
Create a profile posts job
/api/v1/profile/postsCreates an asynchronous job to fetch posts for a tracked personal LinkedIn profile. Results are returned in pages of up to 15 posts (newest first): the completed job's `result` is `{ data: Post[], paginationToken?: string }`. Each post has `contentType`: VIDEO, IMAGE, JOB, LIVE_VIDEO, DOCUMENT or COLLABORATIVE_ARTICLE, the same vocabulary as the keyword search `contentType` filter; it is null when the provider gives no matching type. `paginationToken` is present only while more posts exist — pass it in the next request to page through older posts without overlap; its absence means the end of the reachable history. Reachable depth is bounded (roughly the most recent ~300 posts).
Authenticated with the X-API-Key header.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | LinkedIn personal profile or company `username`, not a full URL. |
limit | integer | no | How many posts this page returns, 1-15 (default 15, which is also the most a page can hold). A smaller page is still followed by a `paginationToken` while more posts exist, and the next page starts where this one ended. A value outside 1-15, a fraction or a non-number is a 400 rather than a silent clamp.default: 15 |
paginationToken | string | no | Opaque cursor for fetching the next page of posts. Omit for the first page. When more posts exist, the completed job's `result` includes a `paginationToken` — pass it verbatim to get the next, non-overlapping page. Tokens are endpoint-specific and expire with history drift (a new post shifts the window by one). |
Request examples
{
"username": "demo-profile"
}{
"username": "demo-profile",
"paginationToken": "page-token-demo"
}Responses
200 — Job accepted. Poll the status endpoint while the job is `pending` or `running`, until it reaches `completed` or `failed`.
| Field | Type | Required | Description |
|---|---|---|---|
jobId | string <uuid> | yes | Identifier of the asynchronous job to poll. |
{
"jobId": "00000000-0000-4000-8000-000000000003"
}| Status | Meaning | Example error |
|---|---|---|
| 400 | The `username` field is required. | username is required |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The authenticated team does not have an active subscription or an unexpired trial. | Team subscription not active |
| 404 | The personal LinkedIn profile is not tracked by the authenticated team, or it has been untracked. | Profile not tracked |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 500 | Unexpected server error — the request failed on our side (e.g. a database error), not because of the request itself. On a WRITE endpoint some work may already have been applied before the failure, so prefer re-reading state over blindly retrying, or you can end up with duplicates. | Failed to list leads |
Create a company posts job
/api/v1/company/postsCreates an asynchronous job to fetch posts for a tracked LinkedIn company page. Results are returned in pages of up to 15 posts (newest first): the completed job's `result` is `{ data: Post[], paginationToken?: string }`. Each post has `contentType`: VIDEO, IMAGE, JOB, LIVE_VIDEO, DOCUMENT or COLLABORATIVE_ARTICLE, the same vocabulary as the keyword search `contentType` filter; it is null when the provider gives no matching type. `paginationToken` is present only while more posts exist — pass it in the next request to page through older posts without overlap; its absence means the end of the reachable history. Reachable depth is bounded (roughly the most recent ~300 posts).
Authenticated with the X-API-Key header.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | LinkedIn personal profile or company `username`, not a full URL. |
paginationToken | string | no | Opaque cursor for fetching the next page of posts. Omit for the first page. When more posts exist, the completed job's `result` includes a `paginationToken` — pass it verbatim to get the next, non-overlapping page. Tokens are endpoint-specific and expire with history drift (a new post shifts the window by one). |
Request examples
{
"username": "demo-company"
}{
"username": "demo-company",
"paginationToken": "page-token-demo"
}Responses
200 — Job accepted. Poll the status endpoint while the job is `pending` or `running`, until it reaches `completed` or `failed`.
| Field | Type | Required | Description |
|---|---|---|---|
jobId | string <uuid> | yes | Identifier of the asynchronous job to poll. |
{
"jobId": "00000000-0000-4000-8000-000000000004"
}| Status | Meaning | Example error |
|---|---|---|
| 400 | The `username` field is required. | username is required |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The authenticated team does not have an active subscription or an unexpired trial. | Team subscription not active |
| 404 | The LinkedIn company page is not tracked by the authenticated team, or it has been untracked. | Company profile not tracked |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 500 | Unexpected server error — the request failed on our side (e.g. a database error), not because of the request itself. On a WRITE endpoint some work may already have been applied before the failure, so prefer re-reading state over blindly retrying, or you can end up with duplicates. | Failed to list leads |
Engagements
Create a post reactions job
/api/v1/post/reactionsFetches reactions for a post this team holds - a tracked post, or one its keyword searches or posts-only watches found. WHICH POSTS - `postUrn` is accepted when this team holds the post: (a) one of the 15 most recent posts of a tracked person, company page or tracked post, or (b) a post one of its keyword searches harvested - every `urn` GET /api/v1/sources/{id}/kept-posts returns with a non-null `url`, a stopped search's included - or one of its posts-only watches saw (GET /api/v1/sources/{id}/posts). COST - the call itself charges nothing. On a tracked post (a) the engagers are also saved as that source's leads - the people its own sync captures - so a person new to that source is enriched and billed 1 credit afterwards, like any of its leads. A keyword-search or posts-only post (b) is only READ: the same rows come back and none is saved, so the pull charges nothing then or later and touches neither the search's `creditCap` nor the team's `dailyCeiling`. REFUSALS - 404 `Post not found` when the team holds no such post; a post only another team holds answers exactly the same. 404 with an error starting `Post not tracked` when the team holds it only as an older post of a tracked profile or company page (outside its 15 most recent) or under an untracked posts-only watch: track the post itself with POST /api/v1/post/track (which captures and charges like any tracked source) and retry once its first sync has finished. The endpoint creates a job in `running`, processes it immediately in the team's queue, and returns a `jobId` for a job that is already `completed` or `failed`. GRAIN and PAGING - `total` is the provider's DECLARED count of reactions on the post: not a row count, not a completeness check. IT CAN EXCEED THE ROWS THE PROVIDER WILL SERVE, never the reverse - measured, a post declaring 1167 delivered 1116 rows over a full 24-page sweep, about 5% short, and that gap is OBSERVED rather than a guaranteed bound. It also drifts within one sweep (1165 on pages 0-2, then 1167), so never cache page 0's value and compare later pages against it. THE VERDICT: a sweep that ends with `hasMore: false` has collected everything available from this endpoint, even when the rows fall short of `total` - your loop is not broken; report the shortfall rather than retrying it. LARGE POSTS HAVE A PROVIDER CEILING OF ABOUT 1,100 PEOPLE. Measured 29 September 2026 on a post declaring 3,490 reactions: asked of the primary, page 0 was answered by the fallback provider and every later page was empty; the fallback then served 22 pages of about 50 — 1,093 unique identified reactors — and an empty page 22, with `total` still 3,490 on every response. That last page reports `hasMore: false` and `exhausted: false`: the sweep is over and the rest will not be served, by either provider, on any retry. A tracked post's sync reports the same wall as `capture.stoppedBy: "provider_limit"` with `capture.coverage` (GET /api/v1/sources/{id}/sync), under the same one-page rule `exhausted` uses here, so the two surfaces agree about the same post. `hasMore` is the termination signal and the only field to branch on; it errs toward `true` (wrong on 7 of 36 measured transitions, always on what turned out to be the last page), so a sweep is never truncated and at worst costs one extra request, and an empty `data` always ends it. Dedupe on `entityUrn`: the provider can repeat a reactor across two pages when its count shifts mid-sweep (2 of those 1116 rows), so a deduped count falls further below `total` still. THAT REPETITION IS DRIFT-SCALE - a couple of rows per thousand, around the boundary the count moved across - so dedupe and move on. A WHOLE PAGE of repeats is a different thing: a page that comes back as an exact copy of the one before it is a paging defect, not drift (one was measured on 2026-09-09 at 332 rows for 282 reactors - exactly one duplicated page - and fixed), so report that rather than deduping around it. `total` REACHES YOU ON EVERY PAGE, including one a fallback provider served with no count of its own (7 of 42 measured results): the post's own stored reaction count fills in, so the field is never absent on the page that carries your rows. It is `null` - present, never omitted - only when no number exists anywhere, meaning no provider count and no stored count. `hasMore` still falls back to page-fullness on a page the provider gave no count for, the rule the comment endpoints use. ECHO `source` BACK OR YOUR SWEEP STOPS AFTER ONE PAGE. Every page reports `source` - "primary" or "fallback" - naming the provider that served it, and it is the only paging state besides `page`. Send it back unchanged as `source` on every request after the first of the same sweep. When the primary has nothing for a post a fallback answers `page: 0`; a `page: 1` request that does not carry `source` is asked of the primary again, gets the same nothing, and reads as the end of pagination. That is the FALLBACK-SERVED SHORTFALL, measured 2026-09-08 at 50 rows of a declared 278 (18% reachable) and 49 of 574 (9%), and on the same post one day earlier at 50 of 104, 52% unreachable - the gap is capped by ONE PAGE regardless of the post, so it grows with the post rather than staying a fixed share. THE SIGNATURE OF HAVING OMITTED IT: rows on page 0 with `source: "fallback"`, an empty page 1, and `exhausted: false`. It is stable across retries, so re-running the sweep is not the remedy; carrying `source` is, and the sweep then pages to exhaustion. Omitting `source` is never an error, only a truncation, and it costs nothing on a primary-served post. THE SERVER NOW DEFAULTS IT, AS A SAFETY NET FOR CALLERS THAT CANNOT SEND IT: `source` omitted on a page past the first is taken to be whichever provider served `page: 0` of the SAME post, when that was within the last hour. An explicit `source` always wins over that default; `page: 0` is always decided afresh, so a post whose provider changes hands is picked up by the next sweep rather than remembered; and a sweep that STARTS past page 0, or pauses more than an hour between pages, has nothing to default from and gets the one-page behaviour above. SEND IT ANYWAY - it is the only thing that makes a sweep correct whatever its timing. `hasMore: false` DOES NOT MEAN YOU HAVE THEM ALL - READ `exhausted`. `hasMore` remains the termination signal and the only field to branch on for whether to request again. `exhausted` answers the other question: `true` means the provider ran out of rows, `false` means it stopped with a page's worth or more still declared and those rows are not reachable by paging, so report the shortfall rather than retrying it. A sweep that merely drifts under `total` (the ~5% above) keeps `exhausted: true` - the line is one full page, the unit the provider serves in. While `hasMore` is true, `exhausted` is `false` and means only "not yet". `total` is never `0` past the end: the provider sends `totalElements: 0` there and Cornersight reports the post's own stored reaction count instead, so a `0` means the post really has no reactions and reaches you only on `page: 0`. PAGE SIZE IS FIXED AT 50 and is not a request parameter: `page` is the only paging control, and a `size` in the body is ignored rather than rejected. So the "last page comes back exactly full" case is not something you can provoke - it happens only when a post's count is an exact multiple of 50, which was true of 14 of 1,000 measured posts. CONTRAST /api/v1/post/comments: its `total` counts something different (top-level comments only) and its `hasMore` is coarser - the same field name does NOT mean the same thing on the two endpoints. IDENTITY MAY BE NULL. Some engagements come back from the data provider carrying the engagement and no identifiable person - a reaction with no reactor, a comment with an empty author. Measured across all stored results on 2026-08-30: about 1 in 116 reactions and 1 in 300 comments. Those rows are returned with every identity field present and set to `null` (never omitted), so every row in `data` has the same shape. THEY ARE COUNTED BUT NOT USABLE AS LEADS: `total` and `hasMore` include them, and Cornersight's own capture skips them, so do NOT read `total` as a count of contactable people. Filter on a null `username` to get the usable subset.
Authenticated with the X-API-Key header.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
postUrn | string | yes | `postUrn` of a LinkedIn post this team holds: one of the 15 most recent posts of a tracked person, company page or tracked post; a post one of its keyword searches harvested (a `urn` from GET /api/v1/sources/{id}/kept-posts with a non-null `url`); or a post one of its posts-only watches saw (GET /api/v1/sources/{id}/posts). A keyword-search or posts-only post is read, never saved, and charges nothing. |
page | integer | no | Zero-based `page` number. The logical default is `0`. Pages are 50 rows and that is not configurable - there is no `size` parameter, and one sent in the body is ignored.default: 0 |
source | "primary" | "fallback" | no | The `source` reported by the PREVIOUS page of this same sweep. Omit on `page: 0`. It keeps the sweep on the provider that is actually serving this post: when the primary has nothing, a fallback answers page 0, and a later page sent without `source` is asked of the primary again, comes back empty, and ends the sweep one page in. An unrecognised value is a 400 rather than an ignored field. THE SERVER DEFAULTS IT when it is omitted past page 0, to whichever provider served `page: 0` of the same post within the last hour; an explicit value always wins, `page: 0` is always decided afresh, and a sweep that starts past page 0 or pauses more than an hour between pages has nothing to default from - so send it anyway. The comment endpoints reject it - they pick a provider per request from an upstream HTTP 500, so there is no sweep-level choice to carry. |
Request examples
{
"postUrn": "urn:li:activity:0000000000000000000",
"page": 0
}{
"postUrn": "urn:li:activity:0000000000000000000",
"page": 1,
"source": "fallback"
}Responses
200 — Job processed immediately. Read the status endpoint for the completed or failed result.
| Field | Type | Required | Description |
|---|---|---|---|
jobId | string <uuid> | yes | Identifier of the asynchronous job to poll. |
{
"jobId": "00000000-0000-4000-8000-000000000005"
}| Status | Meaning | Example error |
|---|---|---|
| 400 | The `postUrn` field is required. | postUrn is required |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The authenticated team does not have an active subscription or an unexpired trial. | Team subscription not active |
| 404 | The post cannot be pulled. `Post not found`: this team holds no such post - not as a tracked post, a keyword search's harvested post or a posts-only watch's post - and a post only ANOTHER team holds answers exactly the same. An error starting `Post not tracked`: the team does hold it, but only as an older post of a tracked profile or company page (outside its 15 most recent) or under an untracked posts-only watch; the message names the fix, POST /api/v1/post/track. | Post not found |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 500 | Unexpected server error — the request failed on our side (e.g. a database error), not because of the request itself. On a WRITE endpoint some work may already have been applied before the failure, so prefer re-reading state over blindly retrying, or you can end up with duplicates. | Failed to list leads |
Create a personal post comments job
/api/v1/post/commentsFetches comments for a personal-profile post this team holds - a tracked post, or one its keyword searches or posts-only watches found. WHICH POSTS - `postUrn` is accepted when this team holds the post: (a) one of the 15 most recent posts of a tracked person, company page or tracked post, or (b) a post one of its keyword searches harvested - every `urn` GET /api/v1/sources/{id}/kept-posts returns with a non-null `url`, a stopped search's included - or one of its posts-only watches saw (GET /api/v1/sources/{id}/posts). COST - the call itself charges nothing. On a tracked post (a) the engagers are also saved as that source's leads - the people its own sync captures - so a person new to that source is enriched and billed 1 credit afterwards, like any of its leads. A keyword-search or posts-only post (b) is only READ: the same rows come back and none is saved, so the pull charges nothing then or later and touches neither the search's `creditCap` nor the team's `dailyCeiling`. REFUSALS - 404 `Post not found` when the team holds no such post; a post only another team holds answers exactly the same. 404 with an error starting `Post not tracked` when the team holds it only as an older post of a tracked profile or company page (outside its 15 most recent) or under an untracked posts-only watch: track the post itself with POST /api/v1/post/track (which captures and charges like any tracked source) and retry once its first sync has finished. The endpoint creates a job in `running`, processes it immediately in the team's queue, and returns a `jobId` for a job that is already `completed` or `failed`. CHOOSE EITHER - the personal and company comment endpoints accept ANY post URN regardless of author type, and neither checks it. They take an IDENTICAL primary path: the same provider call, same request body, same page size, and no sort parameter, so ordering is the provider's default for both. They diverge only when that call FAILS, and each then falls back to a DIFFERENT upstream endpoint and skips the reply-augmentation step, so a fallback result can carry fewer replies. An HTTP 500 always diverts. Anything else depends on how the deployment is configured: with ENGAGER_TRANSIENT_FALLBACK_ENABLED=true (off by default) a 429, 502, 503 or 504 that outlives its retries, and a connection-level or timeout failure, divert as well; with the flag off those are retried and then surfaced as errors. Nothing enforces the personal/company distinction, and enforcing it would cost an extra provider call for the ~11% of posts whose author type we cannot infer. GRAIN AND PAGING - `total` counts TOP-LEVEL comments only. `size` accepts 1–50 top-level comments per page (default 50); `includeReplies` defaults to true, and false returns top-level comments only with `isReply: false`. When replies are included, `data` can be larger than `total`. Do not page against `total`; `hasMore` is true whenever `data` is non-empty, so a full sweep has one final empty page. CONTRAST /api/v1/post/reactions, where `total` is the post's DECLARED reactor count - it can exceed the rows served, so it is not a completeness check there either - and `hasMore` is count-based rather than exact. IDENTITY MAY BE NULL. Some engagements come back from the data provider carrying the engagement and no identifiable person - a reaction with no reactor, a comment with an empty author. Measured across all stored results on 2026-08-30: about 1 in 116 reactions and 1 in 300 comments. Those rows are returned with every identity field present and set to `null` (never omitted), so every row in `data` has the same shape. THEY ARE COUNTED BUT NOT USABLE AS LEADS: `total` and `hasMore` include them, and Cornersight's own capture skips them, so do NOT read `total` as a count of contactable people. Filter on a null `username` to get the usable subset. COMMENT TIME - every row carries `postedAt` (ISO 8601) and `postedAtTimestamp` (epoch milliseconds), the same names and types as a post row, and both are `null` - present, never omitted - only when no comment time can be obtained. HOW THE TIME IS OBTAINED - the provider's own comment time when it sends one (the fallback provider does, and it always wins); otherwise the time the comment's own ID encodes. A LinkedIn comment ID carries its creation time: in `urn:li:comment:(activity:<postId>,<commentId>)`, `commentId` shifted right by 22 bits is the creation time in epoch milliseconds, which reproduced the stated creation time of every published example checked to within 2 ms. That is how rows from the primary provider, whose comment object has no time field, carry one. A decoded time is used only when plausible - after 2003-05-01, not in the future and not before the post - and is otherwise `null`. Neither is ever filled from the post's time. On a lead captured from a comment the same time is `commentPostedAt`, while `postPostedAt` is the POST's time. ROW SHAPE - every row has the same fields whichever provider served the page, as the PostComment schema documents: `id` (the comment's URN - its stable key; dedupe on it), `url` (opens the comment on LinkedIn), `text`, `postedAt`, `postedAtTimestamp`, `isReply`, `parentCommentUrn` (a reply's parent comment, otherwise `null`), `isEdited`, `isPinned`, `totalReactions`, `totalComments` (replies to this comment), `reactionType` and `author` - `type` (`person`, `company`, or `null` when the provider names the commenter only by display name), `username`, `profileUrl`, `url`, `linkedinUrl`, `name`, `firstName`, `lastName`, `headline`, `profilePicture` and `entityUrn`. Unknown values are `null`, never omitted. A company commenting as itself has `author.type` `company`, no `username`, a linkedin.com/company/ URL and its company URN, and is never captured as a lead. `comment` and `commenter` are deprecated aliases of `text` and `author`, kept for existing callers. The completed job's `result` is PostCommentsResult: `data`, `total`, `hasMore` and `source` - `primary` or `fallback`, the provider that served the page, as on reactions. On comments `source` is a report only: there is no `source` request field, and each page is served by whichever provider answers it. REPLY SHARE - replies are typically a minority of rows: in a production measurement of stored comment results, 22% of the rows beyond each result's first 10 were replies and 78% were top-level comments. A given post can differ widely, so filter on `isReply` rather than assuming a ratio.
Authenticated with the X-API-Key header.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
postUrn | string | yes | `postUrn` of a LinkedIn post this team holds: one of the 15 most recent posts of a tracked person, company page or tracked post; a post one of its keyword searches harvested (a `urn` from GET /api/v1/sources/{id}/kept-posts with a non-null `url`); or a post one of its posts-only watches saw (GET /api/v1/sources/{id}/posts). A keyword-search or posts-only post is read, never saved, and charges nothing. |
page | integer | no | Zero-based provider page number.default: 0 |
size | integer | no | Top-level comments per page (1–50).default: 50 |
includeReplies | boolean | no | Include threaded replies in `data`. False returns top-level comments only.default: true |
Request example
{
"postUrn": "urn:li:activity:0000000000000000000",
"page": 0
}Responses
200 — Job processed immediately. Read the status endpoint for the completed or failed result.
| Field | Type | Required | Description |
|---|---|---|---|
jobId | string <uuid> | yes | Identifier of the asynchronous job to poll. |
{
"jobId": "00000000-0000-4000-8000-000000000006"
}| Status | Meaning | Example error |
|---|---|---|
| 400 | The `postUrn` field is required. | postUrn is required |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The authenticated team does not have an active subscription or an unexpired trial. | Team subscription not active |
| 404 | The post cannot be pulled. `Post not found`: this team holds no such post - not as a tracked post, a keyword search's harvested post or a posts-only watch's post - and a post only ANOTHER team holds answers exactly the same. An error starting `Post not tracked`: the team does hold it, but only as an older post of a tracked profile or company page (outside its 15 most recent) or under an untracked posts-only watch; the message names the fix, POST /api/v1/post/track. | Post not found |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 500 | Unexpected server error — the request failed on our side (e.g. a database error), not because of the request itself. On a WRITE endpoint some work may already have been applied before the failure, so prefer re-reading state over blindly retrying, or you can end up with duplicates. | Failed to list leads |
Create a company post comments job
/api/v1/post/company-commentsFetches comments for a company-page post this team holds - a tracked post, or one its keyword searches or posts-only watches found. WHICH POSTS - `postUrn` is accepted when this team holds the post: (a) one of the 15 most recent posts of a tracked person, company page or tracked post, or (b) a post one of its keyword searches harvested - every `urn` GET /api/v1/sources/{id}/kept-posts returns with a non-null `url`, a stopped search's included - or one of its posts-only watches saw (GET /api/v1/sources/{id}/posts). COST - the call itself charges nothing. On a tracked post (a) the engagers are also saved as that source's leads - the people its own sync captures - so a person new to that source is enriched and billed 1 credit afterwards, like any of its leads. A keyword-search or posts-only post (b) is only READ: the same rows come back and none is saved, so the pull charges nothing then or later and touches neither the search's `creditCap` nor the team's `dailyCeiling`. REFUSALS - 404 `Post not found` when the team holds no such post; a post only another team holds answers exactly the same. 404 with an error starting `Post not tracked` when the team holds it only as an older post of a tracked profile or company page (outside its 15 most recent) or under an untracked posts-only watch: track the post itself with POST /api/v1/post/track (which captures and charges like any tracked source) and retry once its first sync has finished. The endpoint creates a job in `running`, processes it immediately in the team's queue, and returns a `jobId` for a job that is already `completed` or `failed`. CHOOSE EITHER - the personal and company comment endpoints accept ANY post URN regardless of author type, and neither checks it. They take an IDENTICAL primary path: the same provider call, same request body, same page size, and no sort parameter, so ordering is the provider's default for both. They diverge only when that call FAILS, and each then falls back to a DIFFERENT upstream endpoint and skips the reply-augmentation step, so a fallback result can carry fewer replies. An HTTP 500 always diverts. Anything else depends on how the deployment is configured: with ENGAGER_TRANSIENT_FALLBACK_ENABLED=true (off by default) a 429, 502, 503 or 504 that outlives its retries, and a connection-level or timeout failure, divert as well; with the flag off those are retried and then surfaced as errors. Nothing enforces the personal/company distinction, and enforcing it would cost an extra provider call for the ~11% of posts whose author type we cannot infer. GRAIN AND PAGING - `total` counts TOP-LEVEL comments only. `size` accepts 1–50 top-level comments per page (default 50); `includeReplies` defaults to true, and false returns top-level comments only with `isReply: false`. When replies are included, `data` can be larger than `total`. Do not page against `total`; `hasMore` is true whenever `data` is non-empty, so a full sweep has one final empty page. CONTRAST /api/v1/post/reactions, where `total` is the post's DECLARED reactor count - it can exceed the rows served, so it is not a completeness check there either - and `hasMore` is count-based rather than exact. IDENTITY MAY BE NULL. Some engagements come back from the data provider carrying the engagement and no identifiable person - a reaction with no reactor, a comment with an empty author. Measured across all stored results on 2026-08-30: about 1 in 116 reactions and 1 in 300 comments. Those rows are returned with every identity field present and set to `null` (never omitted), so every row in `data` has the same shape. THEY ARE COUNTED BUT NOT USABLE AS LEADS: `total` and `hasMore` include them, and Cornersight's own capture skips them, so do NOT read `total` as a count of contactable people. Filter on a null `username` to get the usable subset. COMMENT TIME - every row carries `postedAt` (ISO 8601) and `postedAtTimestamp` (epoch milliseconds), the same names and types as a post row, and both are `null` - present, never omitted - only when no comment time can be obtained. HOW THE TIME IS OBTAINED - the provider's own comment time when it sends one (the fallback provider does, and it always wins); otherwise the time the comment's own ID encodes. A LinkedIn comment ID carries its creation time: in `urn:li:comment:(activity:<postId>,<commentId>)`, `commentId` shifted right by 22 bits is the creation time in epoch milliseconds, which reproduced the stated creation time of every published example checked to within 2 ms. That is how rows from the primary provider, whose comment object has no time field, carry one. A decoded time is used only when plausible - after 2003-05-01, not in the future and not before the post - and is otherwise `null`. Neither is ever filled from the post's time. On a lead captured from a comment the same time is `commentPostedAt`, while `postPostedAt` is the POST's time. ROW SHAPE - every row has the same fields whichever provider served the page, as the PostComment schema documents: `id` (the comment's URN - its stable key; dedupe on it), `url` (opens the comment on LinkedIn), `text`, `postedAt`, `postedAtTimestamp`, `isReply`, `parentCommentUrn` (a reply's parent comment, otherwise `null`), `isEdited`, `isPinned`, `totalReactions`, `totalComments` (replies to this comment), `reactionType` and `author` - `type` (`person`, `company`, or `null` when the provider names the commenter only by display name), `username`, `profileUrl`, `url`, `linkedinUrl`, `name`, `firstName`, `lastName`, `headline`, `profilePicture` and `entityUrn`. Unknown values are `null`, never omitted. A company commenting as itself has `author.type` `company`, no `username`, a linkedin.com/company/ URL and its company URN, and is never captured as a lead. `comment` and `commenter` are deprecated aliases of `text` and `author`, kept for existing callers. The completed job's `result` is PostCommentsResult: `data`, `total`, `hasMore` and `source` - `primary` or `fallback`, the provider that served the page, as on reactions. On comments `source` is a report only: there is no `source` request field, and each page is served by whichever provider answers it. REPLY SHARE - replies are typically a minority of rows: in a production measurement of stored comment results, 22% of the rows beyond each result's first 10 were replies and 78% were top-level comments. A given post can differ widely, so filter on `isReply` rather than assuming a ratio.
Authenticated with the X-API-Key header.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
postUrn | string | yes | `postUrn` of a LinkedIn post this team holds: one of the 15 most recent posts of a tracked person, company page or tracked post; a post one of its keyword searches harvested (a `urn` from GET /api/v1/sources/{id}/kept-posts with a non-null `url`); or a post one of its posts-only watches saw (GET /api/v1/sources/{id}/posts). A keyword-search or posts-only post is read, never saved, and charges nothing. |
page | integer | no | Zero-based provider page number.default: 0 |
size | integer | no | Top-level comments per page (1–50).default: 50 |
includeReplies | boolean | no | Include threaded replies in `data`. False returns top-level comments only.default: true |
Request example
{
"postUrn": "urn:li:activity:0000000000000000000",
"page": 0
}Responses
200 — Job processed immediately. Read the status endpoint for the completed or failed result.
| Field | Type | Required | Description |
|---|---|---|---|
jobId | string <uuid> | yes | Identifier of the asynchronous job to poll. |
{
"jobId": "00000000-0000-4000-8000-000000000007"
}| Status | Meaning | Example error |
|---|---|---|
| 400 | The `postUrn` field is required. | postUrn is required |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The authenticated team does not have an active subscription or an unexpired trial. | Team subscription not active |
| 404 | The post cannot be pulled. `Post not found`: this team holds no such post - not as a tracked post, a keyword search's harvested post or a posts-only watch's post - and a post only ANOTHER team holds answers exactly the same. An error starting `Post not tracked`: the team does hold it, but only as an older post of a tracked profile or company page (outside its 15 most recent) or under an untracked posts-only watch; the message names the fix, POST /api/v1/post/track. | Post not found |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 500 | Unexpected server error — the request failed on our side (e.g. a database error), not because of the request itself. On a WRITE endpoint some work may already have been applied before the failure, so prefer re-reading state over blindly retrying, or you can end up with duplicates. | Failed to list leads |
Jobs
Get job status
/api/v1/jobs/{jobId}/statusReturns the current status for a Public API job owned by the authenticated team. This endpoint does not create jobs. For enrichment jobs, the completed result includes a creditsCharged field indicating how many enriching credits that job consumed. For company enrichment jobs the completed `result` matches the CompanyEnrichmentResult schema. For comment jobs (POST /api/v1/post/comments and /post/company-comments) it matches PostCommentsResult, whose rows are PostComment.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
jobId | string <uuid> | yes | Public API `jobId` returned by an asynchronous job creation endpoint. |
Responses
200 — Current job `status`. Jobs start as `pending`, move to `running` while the worker is processing them, and finish as `completed` or `failed`. When `completed`, the response includes the job `result`. The `result` shape varies by job type and by external LinkedIn API responses.
| Field | Type | Required | Description |
|---|---|---|---|
status | "pending" | "running" | "completed" | "failed" | yes | Lifecycle status. Non-terminal jobs are `pending` or `running`; terminal jobs are `completed` or `failed`. |
result | object | null | yes | Generic `result` object. The exact fields vary by job type and by external LinkedIn API responses. |
error | string | null | yes | Human-readable failure reason when `status` is `failed`; otherwise `null`. Owned Cornersight text - it never contains the upstream data provider's raw response. Wording may change; branch on `errorCode`. |
errorCode | "insufficient_enriching_credits" | "not_found" | "page_out_of_range" | "invalid_request" | "provider_rate_limited" | "provider_access_denied" | "provider_unavailable" | "internal_error" | null | no | Stable, machine-readable reason for the failure when `status` is `failed`; `null` otherwise, and `null` on failures recorded before this field existed. Branch on this rather than parsing `error`, whose wording may change. Values are split by WHO MUST ACT: `insufficient_enriching_credits` - The team's enriching-credit balance is exhausted. YOURS to act on - check GET /api/v1/credits for the balance and reset time. `not_found` - The data provider has no record of the profile, company or post. YOURS to act on - it may be private, renamed, or deleted. `page_out_of_range` - There are no results at the requested `page`. YOURS to act on - request a lower page, and stop paging when a page returns fewer results than the one before it. `invalid_request` - A parameter was rejected. YOURS to act on - check the request against this endpoint's schema. `provider_rate_limited` - The data provider is rate-limiting Cornersight. NOT yours - nothing is wrong with the request; retry in a few minutes. `provider_access_denied` - Cornersight's access to the data provider was refused - a credential or subscription problem on CORNERSIGHT's side. NOT yours - nothing is wrong with your request or your account, and unlike provider_unavailable it will NOT clear on retry; it is logged for us to fix. `provider_unavailable` - The data provider is temporarily unavailable to Cornersight. NOT yours - nothing is wrong with the request; retry shortly. NOTE this also covers the provider reporting that CORNERSIGHT's own account cannot be served, which is never a problem with your credits. `internal_error` - Cornersight could not complete the job. NOT yours - the failure is logged for investigation; retrying may succeed. |
createdAt | string <date-time> | yes | |
startedAt | string | null <date-time> | yes | |
completedAt | string | null <date-time> | yes |
{
"status": "pending",
"result": null,
"error": null,
"createdAt": "2026-01-15T10:00:00.000Z",
"startedAt": null,
"completedAt": null
}| Status | Meaning | Example error |
|---|---|---|
| 400 | The path segment is not a jobId. A well-formed jobId for a job this team does not own answers 404 instead. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The authenticated team does not have an active subscription or an unexpired trial. | Team subscription not active |
| 404 | The `jobId` does not exist or belongs to another team. | Job not found |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 500 | Unexpected server error — the request failed on our side (e.g. a database error), not because of the request itself. On a WRITE endpoint some work may already have been applied before the failure, so prefer re-reading state over blindly retrying, or you can end up with duplicates. | Failed to list leads |
Credits
Get credit balance
/api/v1/creditsRead the authenticated team's enriching credit balance (balance, used, limit, remaining, plan, and the reset dates). Enrichment charges one credit per newly enriched person per source; repeat engagement rows are free; already-enriched, failed, and empty leads are not charged. Read-only, synchronous — returns the balance directly, not a job.
Authenticated with the X-API-Key header.
Responses
200 — The team's enriching credit balance.
| Field | Type | Required | Description |
|---|---|---|---|
balance | integer | yes | Remaining enriching credits (same value as remaining; there is no separate top-up wallet). |
used | integer | yes | Enriching credits consumed in the current billing period. |
limit | integer | yes | Monthly enriching-credit allowance for the team's plan. |
remaining | integer | yes | limit − used, never negative. |
plan | string | yes | The team's plan id (e.g. trial, starter, growth, pro, scale). |
resetAt | string | null <date-time> | yes | Start of the current billing period. |
nextReset | string | null <date-time> | yes | When used resets to 0 (resetAt + 1 month). |
spentToday | integer | yes | What this team's KEYWORD SEARCHES have spent so far today, over the UTC calendar day — the number `dailyCeiling` is enforced against, and the one a "today" bar sits beside the monthly used/limit bar to show. ⚠ NOT A SUBSET OF `used` AT THE MOMENT YOU READ IT: a keyword sweep spends when it writes a lead row, while `used` counts ENRICHMENT charges, which land minutes to hours later as the enrichment poller reaches those leads. The two converge — they are the same money measured at two moments. |
dailyCeiling | integer | null | yes | The most this team's keyword searches may spend between them in one day, or `null` for NO CEILING. A run that would cross it stops AT it (a partial run, not a refusal) and reports `lastRun.stoppedBy` `team_cap` with a reason naming the ceiling and the day's spend; the day's remaining searches are skipped with the same marker and run again tomorrow. `null` is an ANSWER, not a missing value — read `dailyCeilingMode` to learn which answer. |
dailyCeilingMode | "default" | "none" | "custom" | yes | How the ceiling was decided. `default` — nobody chose, so there is no ceiling and `dailyCeiling` is null: Cornersight never sets one. On a free trial there is none whatever is stored. `none` — an admin deliberately turned the ceiling off, and `dailyCeiling` is null. `custom` — an admin set the number. `default` and `none` are DIFFERENT STATES on purpose: the first is a team that has not decided, the second a team that decided not to. Changed in the dashboard under Settings; owners and admins only. |
{
"balance": 58750,
"used": 1250,
"limit": 60000,
"remaining": 58750,
"plan": "pro",
"resetAt": "2026-07-01T00:00:00Z",
"nextReset": "2026-08-01T00:00:00Z"
}| Status | Meaning | Example error |
|---|---|---|
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 402 | The team has no active enriching credit plan. | |
| 403 | The authenticated team does not have an active subscription or an unexpired trial. | Team subscription not active |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 500 | Unexpected server error — the request failed on our side (e.g. a database error), not because of the request itself. On a WRITE endpoint some work may already have been applied before the failure, so prefer re-reading state over blindly retrying, or you can end up with duplicates. | Failed to list leads |
Get credit usage breakdown
/api/v1/credits/usageRead a dated and by-source breakdown of enriching credits charged. Returns totalCharged plus bySource (e.g. api_enrich_profile from the Public API vs worker_sync from the automated sync), byDate (per UTC day) and bySourceId (per tracked source). RECONCILE WITH bySourceId: it sums to totalCharged even when the charged leads are no longer readable, because a source untracked inside the window is still reported there, named and marked status "inactive". bySource says which SYSTEM charged, never which source, so it cannot close a gap between credits and visible leads on its own. Defaults to the current billing period; pass from/to (ISO 8601) to widen or narrow the window. Read-only, synchronous. Reconcile against the balance from getCredits.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
from | string <date-time> | no | ISO 8601 start timestamp (default: current billing-period start). |
to | string <date-time> | no | ISO 8601 end timestamp (default: now). |
Responses
200 — Charged-credit breakdown for the window.
| Field | Type | Required | Description |
|---|---|---|---|
from | string <date-time> | yes | Start of the reported window (inclusive). |
to | string <date-time> | yes | End of the reported window (exclusive). |
totalCharged | integer | yes | Total enriching credits charged in the window. |
bySource | object[] | yes | Credits charged grouped by charge source, most to least. |
└source | string | yes | Charge source, e.g. api_enrich_profile (Public API) or worker_sync (automated sync); unknown for legacy charges. |
└credits | integer | yes | |
byDate | object[] | yes | Credits charged grouped by UTC calendar day, ascending. |
└date | string | yes | UTC calendar day (YYYY-MM-DD). |
└credits | integer | yes | |
bySourceId | object[] | yes | Credits charged grouped by the tracked SOURCE they were spent on, most to least. THIS IS THE FIELD THAT MAKES THE LEDGER RECONCILE: summing it always reproduces `totalCharged`, including for sources whose leads are no longer readable. Untracking is a soft delete, so a source untracked inside the window still appears here — named, and marked `status: "inactive"` — even though GET /api/v1/leads no longer returns its leads and `bySource` only ever said which SYSTEM charged. A spike of credits with no matching leads is normally one of these rows. |
└sourceId | string | null | yes | The tracked source's id — the same id GET /api/v1/sources reports and GET /api/v1/leads filters by as profileId. NULL for charges that can no longer be attributed: the source row was genuinely deleted (the ledger keeps the charge and drops the pointer) or the charge predates the ledger recording one. Present as an explicit null bucket rather than omitted, so the array still sums to totalCharged. |
└username | string | null | yes | The source's LinkedIn handle, post URN, or keyword text. NULL when sourceId is NULL. |
└type | string | null | yes | person, company, post or keyword. NULL when sourceId is NULL. |
└status | string | null | yes | The source's current status. `inactive` means it was untracked, which is the usual explanation for credits whose leads cannot be seen. NULL when sourceId is NULL. |
└credits | integer | yes |
{
"from": "2026-07-01T00:00:00Z",
"to": "2026-07-18T00:00:00Z",
"totalCharged": 1250,
"bySource": [
{
"source": "worker_sync",
"credits": 1180
},
{
"source": "api_enrich_profile",
"credits": 70
}
],
"byDate": [
{
"date": "2026-07-17",
"credits": 42
}
]
}| Status | Meaning | Example error |
|---|---|---|
| 400 | Invalid from/to timestamp. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The authenticated team does not have an active subscription or an unexpired trial. | Team subscription not active |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
| 500 | Unexpected server error — the request failed on our side (e.g. a database error), not because of the request itself. On a WRITE endpoint some work may already have been applied before the failure, so prefer re-reading state over blindly retrying, or you can end up with duplicates. | Failed to list leads |
Account
List API keys and quota
/api/v1/keysList the team's ACTIVE API keys (masked — never the plaintext or hash) plus the per-team quota. A valid key manages its OWN team's keys. Read-only. Returns { keys: [{ id, name, keyPrefix, createdAt, lastUsedAt }], used, max, remaining }.
Authenticated with the X-API-Key header.
Responses
200 — The team's active keys and quota.
| Field | Type | Required | Description |
|---|---|---|---|
keys | object[] | no | |
└id | string | no | |
└name | string | no | |
└keyPrefix | string | no | Masked display form, e.g. cs_af735…e593. The plaintext is never returned by list. |
└createdAt | string <date-time> | no | |
└lastUsedAt | string <date-time> | no | |
used | integer | no | Active keys in use. |
max | integer | no | Maximum active keys per team. |
remaining | integer | no |
| Status | Meaning | Example error |
|---|---|---|
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The authenticated team does not have an active subscription or an unexpired trial. | Team subscription not active |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
Create an API key
/api/v1/keysCreate a new named API key for the team. **The plaintext `apiKey` is returned ONCE in this response and is never retrievable again** — store it immediately. Capped at the per-team `max` (see listApiKeys); at the cap this returns 409 `max_keys_reached`. Keys are SHA-256 hashed at rest.
Authenticated with the X-API-Key header.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | A label for the key, e.g. "CI" or "Zapier". |
Request example
{
"name": "CI"
}Responses
200 — Key created; plaintext returned once.
| Field | Type | Required | Description |
|---|---|---|---|
apiKey | string | no | The plaintext cs_ key — shown ONCE, store it now. |
key | object | no | |
└id | string | no | |
└name | string | no | |
└keyPrefix | string | no | Masked display form, e.g. cs_af735…e593. The plaintext is never returned by list. |
└createdAt | string <date-time> | no | |
└lastUsedAt | string <date-time> | no |
| Status | Meaning | Example error |
|---|---|---|
| 400 | Missing or too-long name. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The authenticated team does not have an active subscription or an unexpired trial. | Team subscription not active |
| 409 | At the per-team key cap (code max_keys_reached); revoke one to create another. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
Revoke an API key
/api/v1/keys/{id}Revoke an API key by id (soft delete). It stops authenticating immediately; the audit row is kept. Team-scoped — a team can only revoke its own keys. Get the id from listApiKeys.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Key id from listApiKeys. |
Responses
200 — Key revoked.
| Field | Type | Required | Description |
|---|---|---|---|
revoked | boolean | no | |
id | string | no |
| Status | Meaning | Example error |
|---|---|---|
| 400 | Invalid key id. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 403 | The authenticated team does not have an active subscription or an unexpired trial. | Team subscription not active |
| 404 | Key not found (wrong id, another team's, or already revoked). | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
Webhooks
Push historical leads for a tracked personal LinkedIn profile
/api/v1/profile/{username}/pushPush ALREADY-CAPTURED leads to this source's configured destination — the historical backfill / replay action that `autoSend: false` ("deliver only on explicit push") refers to, and the public form of the dashboard's "select the leads to push" panel. **Historical vs future.** A push selects on `detectedAt` over the half-open window `[since, until)`. When `until` is omitted it is stamped with the instant the push is accepted, so the cohort is closed and reproducible: everything with `detectedAt` before that instant is HISTORICAL and belongs to this push; everything at or after it is FUTURE and belongs to auto-send. A push never races capture. **ICP vs all.** `scope: "all"` selects every eligible lead on the source; `scope: "icp"` selects only those with `isIcp: true` — the same flag `GET /api/v1/leads` returns and the same one the source's ICP rules write. NOTE the interaction with the webhook's own `icpOnly` flag: `icpOnly` filters at DELIVERY time, so an `all` push against an `icpOnly: true` webhook queues everything and delivers only the ICP subset — the rest are reported as `held` by the status endpoint. **Eligibility.** Only `enriched` leads are ever selected: the delivered payload is built from enrichment fields and the delivery poller only reads enriched rows, so an un-enriched lead moved to `pending` would never be sent. The response's `notSelected` counts the leads in the same scope and window that were skipped for that reason: `awaitingEnrichment` (still being enriched — auto-send or a later push delivers them) and `enrichmentFailed` (enrichment ended without data; never deliverable). **Idempotency.** Supply `idempotencyKey` to make a retry safe: a second push with the same key returns the FIRST push's result with `replayed: true` and queues nothing. The same key with different parameters is a `409`. Without a key every call is a new push (and a re-push of an already-delivered lead is a legitimate replay — delivery is at-least-once, dedupe on `data.leadId`). **Progress.** The response carries a `pushId` and `statusUrl`; poll `GET /api/v1/push/{pushId}` for the live status distribution of exactly this push's cohort. **Size.** At most 2000 leads per push, oldest first. A larger cohort returns `truncated: true` and a `nextSince`; repeat the push with `since: nextSince` (and a NEW idempotency key) to continue. **Cost.** A push charges no credits: it re-queues leads that were already captured and enriched, and re-enriches none, so a backfill or replay of any size is free. Saving or changing a webhook URL through this API does NOT deliver history — this operation is the only public way to send it. **UNTRACKED SOURCES CANNOT BE PUSHED.** A source you untracked is not actionable: this returns `404`, the same as a source that was never tracked. Untracking is a soft delete, so its leads are still in the database — but they are out of every view AND out of every delivery, because a push is the operation that acts on leads that already exist, and sending data the product told you was gone into your CRM is the one outcome deletion has to prevent. The same rule governs this source's webhook and ICP configuration, which is what a push delivers to. KEYWORD searches are the deliberate exception, here as everywhere: stopping a search keeps its leads readable, so pushing them stays available.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | Public identifier of the tracked profile (not a full URL), e.g. demo-profile. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
scope | "all" | "icp" | no | Which leads to select. `all` = every eligible lead on the source. `icp` = only leads with `isIcp: true`. This is SELECTION, not delivery filtering: the webhook's own `icpOnly` flag still applies afterwards, so an `all` push to an `icpOnly` webhook delivers only the ICP subset and reports the rest as `held`.default: "all" |
since | string <date-time> | no | Inclusive lower bound on the lead's `detectedAt`. Omit for "from the beginning". Use the `nextSince` from a truncated push to continue it. |
until | string <date-time> | no | EXCLUSIVE upper bound on the lead's `detectedAt`. Omit and it is stamped with the instant the push is accepted — which is what makes the cohort historical, closed and reproducible. Leads detected at or after that instant are FUTURE and are delivered by auto-send instead. |
idempotencyKey | string | no | Retry-safety opt-in, 1-200 characters of letters, digits, dot, underscore, colon or hyphen. A second push with the same key returns the first push's result with `replayed: true` and queues NOTHING; the same key with different parameters returns 409. Omit it and every call is a distinct push. |
dryRun | boolean | no | Count the cohort and change nothing: no leads are queued, no push is recorded, and the idempotencyKey is not consumed. Use it to see how large an `all` vs `icp` selection is before sending.default: false |
Request examples
{
"scope": "all",
"idempotencyKey": "backfill-2026-09-08"
}{
"scope": "icp",
"since": "2026-08-09T00:00:00Z",
"idempotencyKey": "icp-sept"
}{
"scope": "icp",
"dryRun": true
}Responses
200 — The push was accepted (or replayed). Delivery happens asynchronously; poll `statusUrl`.
| Field | Type | Required | Description |
|---|---|---|---|
pushId | string <uuid> | yes | Id of the recorded push, for `GET /api/v1/push/{pushId}`. Null on a dry run, which records nothing. |
username | string | no | The source's identifier as stored — for a keyword search this is its keyword text, not its id. |
profileType | "person" | "company" | "keyword" | no | |
scope | "all" | "icp" | yes | |
selection | object | no | The window this push resolved to. `until` is always concrete, even when it was omitted from the request. |
└since | string <date-time> | no | |
└until | string <date-time> | no | |
idempotencyKey | string | no | The key this push is recorded under — generated if you did not supply one. Null on a dry run. |
createdAt | string <date-time> | no | |
counts | object | yes | |
└selected | integer | no | Eligible leads matching the scope and window. |
└queued | integer | no | Of those, the ones this push moved to `pending`. Lower than `selected` when some were already queued or in flight — those are left alone rather than re-queued, which is what stops a push racing a delivery already under way. |
statusUrl | string | no | Where to poll progress. Null on a dry run. |
replayed | boolean | yes | True when this call matched an existing `idempotencyKey` and therefore queued nothing — the counts are the ORIGINAL push's. |
dryRun | boolean | yes | |
truncated | boolean | no | True when the cohort was larger than the 2000-lead per-push limit and only the oldest 2000 were taken. |
nextSince | string <date-time> | no | When `truncated`, the `since` to repeat the push with (using a NEW idempotencyKey) to continue. Inclusive, so a lead sharing the boundary timestamp is re-selected rather than skipped — re-selecting is harmless, skipping would silently lose a lead. |
notSelected | object | no | Leads in the same scope and window that this push did NOT select because they are not enriched — so a push that selects few or none says why. Null on a replay (it was not recorded) or if the count could not be taken. |
└awaitingEnrichment | integer | no | Enrichment is still owed. Not queued by this push: once enriched they go out through auto-send (when it is on) or a later push. |
└enrichmentFailed | integer | no | Enrichment ended without data (failed, empty or skipped). These are never delivered: the payload is built from enrichment fields. |
| Status | Meaning | Example error |
|---|---|---|
| 400 | Invalid scope, window or idempotencyKey — or the source has no delivery destination configured. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 404 | No such tracked source for this team — or one you untracked, which is not actionable (its leads are retained but never delivered). A stopped KEYWORD search is the exception and still pushes. | |
| 409 | `idempotencyKey` was already used for a push with different parameters. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
Push historical leads for a tracked LinkedIn company page
/api/v1/company/{username}/pushPush ALREADY-CAPTURED leads to this source's configured destination — the historical backfill / replay action that `autoSend: false` ("deliver only on explicit push") refers to, and the public form of the dashboard's "select the leads to push" panel. **Historical vs future.** A push selects on `detectedAt` over the half-open window `[since, until)`. When `until` is omitted it is stamped with the instant the push is accepted, so the cohort is closed and reproducible: everything with `detectedAt` before that instant is HISTORICAL and belongs to this push; everything at or after it is FUTURE and belongs to auto-send. A push never races capture. **ICP vs all.** `scope: "all"` selects every eligible lead on the source; `scope: "icp"` selects only those with `isIcp: true` — the same flag `GET /api/v1/leads` returns and the same one the source's ICP rules write. NOTE the interaction with the webhook's own `icpOnly` flag: `icpOnly` filters at DELIVERY time, so an `all` push against an `icpOnly: true` webhook queues everything and delivers only the ICP subset — the rest are reported as `held` by the status endpoint. **Eligibility.** Only `enriched` leads are ever selected: the delivered payload is built from enrichment fields and the delivery poller only reads enriched rows, so an un-enriched lead moved to `pending` would never be sent. The response's `notSelected` counts the leads in the same scope and window that were skipped for that reason: `awaitingEnrichment` (still being enriched — auto-send or a later push delivers them) and `enrichmentFailed` (enrichment ended without data; never deliverable). **Idempotency.** Supply `idempotencyKey` to make a retry safe: a second push with the same key returns the FIRST push's result with `replayed: true` and queues nothing. The same key with different parameters is a `409`. Without a key every call is a new push (and a re-push of an already-delivered lead is a legitimate replay — delivery is at-least-once, dedupe on `data.leadId`). **Progress.** The response carries a `pushId` and `statusUrl`; poll `GET /api/v1/push/{pushId}` for the live status distribution of exactly this push's cohort. **Size.** At most 2000 leads per push, oldest first. A larger cohort returns `truncated: true` and a `nextSince`; repeat the push with `since: nextSince` (and a NEW idempotency key) to continue. **Cost.** A push charges no credits: it re-queues leads that were already captured and enriched, and re-enriches none, so a backfill or replay of any size is free. Saving or changing a webhook URL through this API does NOT deliver history — this operation is the only public way to send it. **UNTRACKED SOURCES CANNOT BE PUSHED.** A source you untracked is not actionable: this returns `404`, the same as a source that was never tracked. Untracking is a soft delete, so its leads are still in the database — but they are out of every view AND out of every delivery, because a push is the operation that acts on leads that already exist, and sending data the product told you was gone into your CRM is the one outcome deletion has to prevent. The same rule governs this source's webhook and ICP configuration, which is what a push delivers to. KEYWORD searches are the deliberate exception, here as everywhere: stopping a search keeps its leads readable, so pushing them stays available.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
username | string | yes | Public identifier of the tracked company page (not a full URL), e.g. demo-company. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
scope | "all" | "icp" | no | Which leads to select. `all` = every eligible lead on the source. `icp` = only leads with `isIcp: true`. This is SELECTION, not delivery filtering: the webhook's own `icpOnly` flag still applies afterwards, so an `all` push to an `icpOnly` webhook delivers only the ICP subset and reports the rest as `held`.default: "all" |
since | string <date-time> | no | Inclusive lower bound on the lead's `detectedAt`. Omit for "from the beginning". Use the `nextSince` from a truncated push to continue it. |
until | string <date-time> | no | EXCLUSIVE upper bound on the lead's `detectedAt`. Omit and it is stamped with the instant the push is accepted — which is what makes the cohort historical, closed and reproducible. Leads detected at or after that instant are FUTURE and are delivered by auto-send instead. |
idempotencyKey | string | no | Retry-safety opt-in, 1-200 characters of letters, digits, dot, underscore, colon or hyphen. A second push with the same key returns the first push's result with `replayed: true` and queues NOTHING; the same key with different parameters returns 409. Omit it and every call is a distinct push. |
dryRun | boolean | no | Count the cohort and change nothing: no leads are queued, no push is recorded, and the idempotencyKey is not consumed. Use it to see how large an `all` vs `icp` selection is before sending.default: false |
Request examples
{
"scope": "all",
"idempotencyKey": "backfill-2026-09-08"
}{
"scope": "icp",
"since": "2026-08-09T00:00:00Z",
"idempotencyKey": "icp-sept"
}{
"scope": "icp",
"dryRun": true
}Responses
200 — The push was accepted (or replayed). Delivery happens asynchronously; poll `statusUrl`.
| Field | Type | Required | Description |
|---|---|---|---|
pushId | string <uuid> | yes | Id of the recorded push, for `GET /api/v1/push/{pushId}`. Null on a dry run, which records nothing. |
username | string | no | The source's identifier as stored — for a keyword search this is its keyword text, not its id. |
profileType | "person" | "company" | "keyword" | no | |
scope | "all" | "icp" | yes | |
selection | object | no | The window this push resolved to. `until` is always concrete, even when it was omitted from the request. |
└since | string <date-time> | no | |
└until | string <date-time> | no | |
idempotencyKey | string | no | The key this push is recorded under — generated if you did not supply one. Null on a dry run. |
createdAt | string <date-time> | no | |
counts | object | yes | |
└selected | integer | no | Eligible leads matching the scope and window. |
└queued | integer | no | Of those, the ones this push moved to `pending`. Lower than `selected` when some were already queued or in flight — those are left alone rather than re-queued, which is what stops a push racing a delivery already under way. |
statusUrl | string | no | Where to poll progress. Null on a dry run. |
replayed | boolean | yes | True when this call matched an existing `idempotencyKey` and therefore queued nothing — the counts are the ORIGINAL push's. |
dryRun | boolean | yes | |
truncated | boolean | no | True when the cohort was larger than the 2000-lead per-push limit and only the oldest 2000 were taken. |
nextSince | string <date-time> | no | When `truncated`, the `since` to repeat the push with (using a NEW idempotencyKey) to continue. Inclusive, so a lead sharing the boundary timestamp is re-selected rather than skipped — re-selecting is harmless, skipping would silently lose a lead. |
notSelected | object | no | Leads in the same scope and window that this push did NOT select because they are not enriched — so a push that selects few or none says why. Null on a replay (it was not recorded) or if the count could not be taken. |
└awaitingEnrichment | integer | no | Enrichment is still owed. Not queued by this push: once enriched they go out through auto-send (when it is on) or a later push. |
└enrichmentFailed | integer | no | Enrichment ended without data (failed, empty or skipped). These are never delivered: the payload is built from enrichment fields. |
| Status | Meaning | Example error |
|---|---|---|
| 400 | Invalid scope, window or idempotencyKey — or the source has no delivery destination configured. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 404 | No such tracked source for this team — or one you untracked, which is not actionable (its leads are retained but never delivered). A stopped KEYWORD search is the exception and still pushes. | |
| 409 | `idempotencyKey` was already used for a push with different parameters. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
Push historical leads for a keyword search
/api/v1/keyword/{id}/pushPush ALREADY-CAPTURED leads to this source's configured destination — the historical backfill / replay action that `autoSend: false` ("deliver only on explicit push") refers to, and the public form of the dashboard's "select the leads to push" panel. **Historical vs future.** A push selects on `detectedAt` over the half-open window `[since, until)`. When `until` is omitted it is stamped with the instant the push is accepted, so the cohort is closed and reproducible: everything with `detectedAt` before that instant is HISTORICAL and belongs to this push; everything at or after it is FUTURE and belongs to auto-send. A push never races capture. **ICP vs all.** `scope: "all"` selects every eligible lead on the source; `scope: "icp"` selects only those with `isIcp: true` — the same flag `GET /api/v1/leads` returns and the same one the source's ICP rules write. NOTE the interaction with the webhook's own `icpOnly` flag: `icpOnly` filters at DELIVERY time, so an `all` push against an `icpOnly: true` webhook queues everything and delivers only the ICP subset — the rest are reported as `held` by the status endpoint. **Eligibility.** Only `enriched` leads are ever selected: the delivered payload is built from enrichment fields and the delivery poller only reads enriched rows, so an un-enriched lead moved to `pending` would never be sent. The response's `notSelected` counts the leads in the same scope and window that were skipped for that reason: `awaitingEnrichment` (still being enriched — auto-send or a later push delivers them) and `enrichmentFailed` (enrichment ended without data; never deliverable). **Idempotency.** Supply `idempotencyKey` to make a retry safe: a second push with the same key returns the FIRST push's result with `replayed: true` and queues nothing. The same key with different parameters is a `409`. Without a key every call is a new push (and a re-push of an already-delivered lead is a legitimate replay — delivery is at-least-once, dedupe on `data.leadId`). **Progress.** The response carries a `pushId` and `statusUrl`; poll `GET /api/v1/push/{pushId}` for the live status distribution of exactly this push's cohort. **Size.** At most 2000 leads per push, oldest first. A larger cohort returns `truncated: true` and a `nextSince`; repeat the push with `since: nextSince` (and a NEW idempotency key) to continue. **Cost.** A push charges no credits: it re-queues leads that were already captured and enriched, and re-enriches none, so a backfill or replay of any size is free. Saving or changing a webhook URL through this API does NOT deliver history — this operation is the only public way to send it. **UNTRACKED SOURCES CANNOT BE PUSHED.** A source you untracked is not actionable: this returns `404`, the same as a source that was never tracked. Untracking is a soft delete, so its leads are still in the database — but they are out of every view AND out of every delivery, because a push is the operation that acts on leads that already exist, and sending data the product told you was gone into your CRM is the one outcome deletion has to prevent. The same rule governs this source's webhook and ICP configuration, which is what a push delivers to. KEYWORD searches are the deliberate exception, here as everywhere: stopping a search keeps its leads readable, so pushing them stays available.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | string <uuid> | yes | The keyword search's source id, from GET /api/v1/sources. A keyword search is addressed by id, not by its keyword text. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
scope | "all" | "icp" | no | Which leads to select. `all` = every eligible lead on the source. `icp` = only leads with `isIcp: true`. This is SELECTION, not delivery filtering: the webhook's own `icpOnly` flag still applies afterwards, so an `all` push to an `icpOnly` webhook delivers only the ICP subset and reports the rest as `held`.default: "all" |
since | string <date-time> | no | Inclusive lower bound on the lead's `detectedAt`. Omit for "from the beginning". Use the `nextSince` from a truncated push to continue it. |
until | string <date-time> | no | EXCLUSIVE upper bound on the lead's `detectedAt`. Omit and it is stamped with the instant the push is accepted — which is what makes the cohort historical, closed and reproducible. Leads detected at or after that instant are FUTURE and are delivered by auto-send instead. |
idempotencyKey | string | no | Retry-safety opt-in, 1-200 characters of letters, digits, dot, underscore, colon or hyphen. A second push with the same key returns the first push's result with `replayed: true` and queues NOTHING; the same key with different parameters returns 409. Omit it and every call is a distinct push. |
dryRun | boolean | no | Count the cohort and change nothing: no leads are queued, no push is recorded, and the idempotencyKey is not consumed. Use it to see how large an `all` vs `icp` selection is before sending.default: false |
Request examples
{
"scope": "all",
"idempotencyKey": "backfill-2026-09-08"
}{
"scope": "icp",
"since": "2026-08-09T00:00:00Z",
"idempotencyKey": "icp-sept"
}{
"scope": "icp",
"dryRun": true
}Responses
200 — The push was accepted (or replayed). Delivery happens asynchronously; poll `statusUrl`.
| Field | Type | Required | Description |
|---|---|---|---|
pushId | string <uuid> | yes | Id of the recorded push, for `GET /api/v1/push/{pushId}`. Null on a dry run, which records nothing. |
username | string | no | The source's identifier as stored — for a keyword search this is its keyword text, not its id. |
profileType | "person" | "company" | "keyword" | no | |
scope | "all" | "icp" | yes | |
selection | object | no | The window this push resolved to. `until` is always concrete, even when it was omitted from the request. |
└since | string <date-time> | no | |
└until | string <date-time> | no | |
idempotencyKey | string | no | The key this push is recorded under — generated if you did not supply one. Null on a dry run. |
createdAt | string <date-time> | no | |
counts | object | yes | |
└selected | integer | no | Eligible leads matching the scope and window. |
└queued | integer | no | Of those, the ones this push moved to `pending`. Lower than `selected` when some were already queued or in flight — those are left alone rather than re-queued, which is what stops a push racing a delivery already under way. |
statusUrl | string | no | Where to poll progress. Null on a dry run. |
replayed | boolean | yes | True when this call matched an existing `idempotencyKey` and therefore queued nothing — the counts are the ORIGINAL push's. |
dryRun | boolean | yes | |
truncated | boolean | no | True when the cohort was larger than the 2000-lead per-push limit and only the oldest 2000 were taken. |
nextSince | string <date-time> | no | When `truncated`, the `since` to repeat the push with (using a NEW idempotencyKey) to continue. Inclusive, so a lead sharing the boundary timestamp is re-selected rather than skipped — re-selecting is harmless, skipping would silently lose a lead. |
notSelected | object | no | Leads in the same scope and window that this push did NOT select because they are not enriched — so a push that selects few or none says why. Null on a replay (it was not recorded) or if the count could not be taken. |
└awaitingEnrichment | integer | no | Enrichment is still owed. Not queued by this push: once enriched they go out through auto-send (when it is on) or a later push. |
└enrichmentFailed | integer | no | Enrichment ended without data (failed, empty or skipped). These are never delivered: the payload is built from enrichment fields. |
| Status | Meaning | Example error |
|---|---|---|
| 400 | Invalid scope, window or idempotencyKey — or the source has no delivery destination configured. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 404 | No such tracked source for this team — or one you untracked, which is not actionable (its leads are retained but never delivered). A stopped KEYWORD search is the exception and still pushes. | |
| 409 | `idempotencyKey` was already used for a push with different parameters. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
Push historical leads for any source, by source id
/api/v1/sources/{id}/pushTHE SAME PUSH, ADDRESSED BY SOURCE ID, FOR EVERY KIND OF SOURCE — a person, a company page, a keyword search or a TRACKED POST. It is the only push a tracked post has: the other push routes are keyed by a LinkedIn handle or a keyword search's id, and a post's identifier is an activity URN, so its leads — including the ones captured before its webhook was set — could not be backfilled or replayed after a failed delivery. Body, selection, idempotency, paging and response are exactly those of the other push routes. For a tracked post it is also what `autoSend: false` (PUT /api/v1/sources/{id}/webhook) holds new leads for. Push ALREADY-CAPTURED leads to this source's configured destination — the historical backfill / replay action that `autoSend: false` ("deliver only on explicit push") refers to, and the public form of the dashboard's "select the leads to push" panel. **Historical vs future.** A push selects on `detectedAt` over the half-open window `[since, until)`. When `until` is omitted it is stamped with the instant the push is accepted, so the cohort is closed and reproducible: everything with `detectedAt` before that instant is HISTORICAL and belongs to this push; everything at or after it is FUTURE and belongs to auto-send. A push never races capture. **ICP vs all.** `scope: "all"` selects every eligible lead on the source; `scope: "icp"` selects only those with `isIcp: true` — the same flag `GET /api/v1/leads` returns and the same one the source's ICP rules write. NOTE the interaction with the webhook's own `icpOnly` flag: `icpOnly` filters at DELIVERY time, so an `all` push against an `icpOnly: true` webhook queues everything and delivers only the ICP subset — the rest are reported as `held` by the status endpoint. **Eligibility.** Only `enriched` leads are ever selected: the delivered payload is built from enrichment fields and the delivery poller only reads enriched rows, so an un-enriched lead moved to `pending` would never be sent. The response's `notSelected` counts the leads in the same scope and window that were skipped for that reason: `awaitingEnrichment` (still being enriched — auto-send or a later push delivers them) and `enrichmentFailed` (enrichment ended without data; never deliverable). **Idempotency.** Supply `idempotencyKey` to make a retry safe: a second push with the same key returns the FIRST push's result with `replayed: true` and queues nothing. The same key with different parameters is a `409`. Without a key every call is a new push (and a re-push of an already-delivered lead is a legitimate replay — delivery is at-least-once, dedupe on `data.leadId`). **Progress.** The response carries a `pushId` and `statusUrl`; poll `GET /api/v1/push/{pushId}` for the live status distribution of exactly this push's cohort. **Size.** At most 2000 leads per push, oldest first. A larger cohort returns `truncated: true` and a `nextSince`; repeat the push with `since: nextSince` (and a NEW idempotency key) to continue. **Cost.** A push charges no credits: it re-queues leads that were already captured and enriched, and re-enriches none, so a backfill or replay of any size is free. Saving or changing a webhook URL through this API does NOT deliver history — this operation is the only public way to send it. **UNTRACKED SOURCES CANNOT BE PUSHED.** A source you untracked is not actionable: this returns `404`, the same as a source that was never tracked. Untracking is a soft delete, so its leads are still in the database — but they are out of every view AND out of every delivery, because a push is the operation that acts on leads that already exist, and sending data the product told you was gone into your CRM is the one outcome deletion has to prevent. The same rule governs this source's webhook and ICP configuration, which is what a push delivers to. KEYWORD searches are the deliberate exception, here as everywhere: stopping a search keeps its leads readable, so pushing them stays available.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | string <uuid> | yes | The source id from GET /api/v1/sources, for ANY kind: a person, a company page, a keyword search or a tracked post. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
scope | "all" | "icp" | no | Which leads to select. `all` = every eligible lead on the source. `icp` = only leads with `isIcp: true`. This is SELECTION, not delivery filtering: the webhook's own `icpOnly` flag still applies afterwards, so an `all` push to an `icpOnly` webhook delivers only the ICP subset and reports the rest as `held`.default: "all" |
since | string <date-time> | no | Inclusive lower bound on the lead's `detectedAt`. Omit for "from the beginning". Use the `nextSince` from a truncated push to continue it. |
until | string <date-time> | no | EXCLUSIVE upper bound on the lead's `detectedAt`. Omit and it is stamped with the instant the push is accepted — which is what makes the cohort historical, closed and reproducible. Leads detected at or after that instant are FUTURE and are delivered by auto-send instead. |
idempotencyKey | string | no | Retry-safety opt-in, 1-200 characters of letters, digits, dot, underscore, colon or hyphen. A second push with the same key returns the first push's result with `replayed: true` and queues NOTHING; the same key with different parameters returns 409. Omit it and every call is a distinct push. |
dryRun | boolean | no | Count the cohort and change nothing: no leads are queued, no push is recorded, and the idempotencyKey is not consumed. Use it to see how large an `all` vs `icp` selection is before sending.default: false |
Request examples
{
"scope": "all",
"idempotencyKey": "backfill-2026-09-08"
}{
"scope": "icp",
"since": "2026-08-09T00:00:00Z",
"idempotencyKey": "icp-sept"
}{
"scope": "icp",
"dryRun": true
}Responses
200 — The push was accepted (or replayed). Delivery happens asynchronously; poll `statusUrl`.
| Field | Type | Required | Description |
|---|---|---|---|
pushId | string <uuid> | yes | Id of the recorded push, for `GET /api/v1/push/{pushId}`. Null on a dry run, which records nothing. |
username | string | no | The source's identifier as stored — for a keyword search this is its keyword text, not its id. |
profileType | "person" | "company" | "keyword" | no | |
scope | "all" | "icp" | yes | |
selection | object | no | The window this push resolved to. `until` is always concrete, even when it was omitted from the request. |
└since | string <date-time> | no | |
└until | string <date-time> | no | |
idempotencyKey | string | no | The key this push is recorded under — generated if you did not supply one. Null on a dry run. |
createdAt | string <date-time> | no | |
counts | object | yes | |
└selected | integer | no | Eligible leads matching the scope and window. |
└queued | integer | no | Of those, the ones this push moved to `pending`. Lower than `selected` when some were already queued or in flight — those are left alone rather than re-queued, which is what stops a push racing a delivery already under way. |
statusUrl | string | no | Where to poll progress. Null on a dry run. |
replayed | boolean | yes | True when this call matched an existing `idempotencyKey` and therefore queued nothing — the counts are the ORIGINAL push's. |
dryRun | boolean | yes | |
truncated | boolean | no | True when the cohort was larger than the 2000-lead per-push limit and only the oldest 2000 were taken. |
nextSince | string <date-time> | no | When `truncated`, the `since` to repeat the push with (using a NEW idempotencyKey) to continue. Inclusive, so a lead sharing the boundary timestamp is re-selected rather than skipped — re-selecting is harmless, skipping would silently lose a lead. |
notSelected | object | no | Leads in the same scope and window that this push did NOT select because they are not enriched — so a push that selects few or none says why. Null on a replay (it was not recorded) or if the count could not be taken. |
└awaitingEnrichment | integer | no | Enrichment is still owed. Not queued by this push: once enriched they go out through auto-send (when it is on) or a later push. |
└enrichmentFailed | integer | no | Enrichment ended without data (failed, empty or skipped). These are never delivered: the payload is built from enrichment fields. |
| Status | Meaning | Example error |
|---|---|---|
| 400 | The path segment is not a source id (a UUID from GET /api/v1/sources), or an invalid scope, window or idempotencyKey — or the source has no delivery destination configured. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 404 | No such tracked source for this team — or one you untracked, which is not actionable (its leads are retained but never delivered). A stopped KEYWORD search is the exception and still pushes. | |
| 409 | `idempotencyKey` was already used for a push with different parameters. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |
Get the progress of a lead push
/api/v1/push/{pushId}Live progress for one recorded push, over EXACTLY the leads that push selected. This is why a push is recorded at all: `webhookStatus` on a lead is shared by every delivery path (auto-send, the daily sync, an earlier push), so it cannot answer "how far along is the push I started?". The counts here are the current status distribution of this push's own cohort. `delivered` is `sent`, `failed` is a delivery that exhausted its attempts, and `held` is `no_webhook` — the delivery path looked at the lead and had nothing to send it to, which in practice means an `icpOnly` webhook rejecting a non-ICP lead. `done` is true once nothing is `pending` or `sending`. `status` says where the push stands in one word — `queued` means none of its leads has been attempted yet — and `stalled` is true when its leads are still unattempted five minutes (`stalledAfterSeconds`) after the push was accepted: a delay on Cornersight's side, not your endpoint. A healthy push is attempted within about a minute.
Authenticated with the X-API-Key header.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
pushId | string <uuid> | yes | The pushId returned by a push operation. |
Responses
200 — Current progress for the push.
| Field | Type | Required | Description |
|---|---|---|---|
pushId | string <uuid> | yes | |
username | string | no | |
profileType | string | no | |
scope | "all" | "icp" | no | |
selection | object | no | |
└since | string <date-time> | no | |
└until | string <date-time> | no | |
idempotencyKey | string | no | |
createdAt | string <date-time> | no | |
counts | object | yes | `selected` and `queued` are frozen as of the push; the five status counts are LIVE, over exactly this push's cohort. |
└selected | integer | no | |
└queued | integer | no | |
└pending | integer | no | Queued and NOT YET ATTEMPTED — not yet claimed by the delivery poller. |
└sending | integer | no | Claimed by a delivery in flight. The same number as `delivering`, under the name this route shipped with. |
└delivering | integer | no | Claimed by a delivery in flight: attempted, outcome not yet recorded. |
└delivered | integer | no | `sent` — every configured destination accepted it. |
└failed | integer | no | A destination rejected it or could not be reached after its automatic attempts (3 within the same sweep, for a transport error, 408, 429 or 5xx; a 4xx is not retried). Re-push to try again. |
└held | integer | no | `no_webhook` — the delivery path had nothing to send this lead to. Normally an `icpOnly` webhook rejecting a non-ICP lead, which is what an `all`-scope push against an ICP-only webhook produces. |
status | "queued" | "delivering" | "delivered" | "failed" | "held" | no | Where the push stands in one word. `queued`: none of its leads has been attempted yet. `delivering`: some attempted, some not yet settled. Once settled: `failed` if any lead failed, else `delivered` if any was sent, else `held` (the webhook's icpOnly filter took every lead). |
stalled | boolean | no | True when leads of this push are still `pending` — never attempted — `stalledAfterSeconds` or more after the push was accepted. A healthy push is attempted within about a minute, so this is a delay on Cornersight's side, not your endpoint; ops is alerted on the same condition. |
stalledAfterSeconds | integer | no | The threshold `stalled` is judged against: 300. |
done | boolean | yes | True once nothing is `pending` or `sending`. |
settled | integer | no | delivered + failed + held. |
total | integer | no | All five live counts summed. Can differ from `selected` if leads were deleted after the push. |
percent | integer | no | settled/total as a whole percentage; 100 when the cohort is empty. |
| Status | Meaning | Example error |
|---|---|---|
| 400 | The path segment is not a pushId. | |
| 401 | `X-API-Key` is missing or invalid. | Missing X-API-Key header |
| 404 | No push with that id for this team. | |
| 429 | Rate limit exceeded for the team. Includes `Retry-After` (seconds) and `X-RateLimit-*` headers. | Rate limit exceeded. Retry after the window resets. |