Where health lives
Open Senders, click the account row, open the Smart Limits tab. The warmup card is at the top: the score, a band label, and a table of Metric, Health and Comment, one row per signal. A healthy signal readsok; an unhealthy one shows the percentage it costs and the reason. Below it, Incident
logs holds the Limit-hit log and the Block log, then the per-action caps. The Senders list
carries a Safety column for a quick per-account read.
The warmup score
One integer from 0 to 100, recomputed on every snapshot asround(100 * (1 - p1) * ... * (1 - p6)): six
signals each remove a fraction of what is left, so they compound rather than add up. A signal with no
penalty is left out of the breakdown, which is why warmup_breakdown comes back empty on a healthy
account. The table itself always lists all six signals, the healthy ones marked ok.
A dormant account carries the full 90% activity penalty, which alone caps its score at 10. The band label under
the number is presentation only, nothing in the engine keys off it:
New below 50, Warming up from 50 to 89,
Established at 90 or above.
What counts as activity, and what does not
The floor is 80 platform-executed sends per 30 days, read from the platform’s own activity log. It deliberately ignores the connection and message tables, because a freshly connected account imports a backlog of previously sent invitations whose timestamps would make a dormant account look busy. So sends you make by hand in LinkedIn earn no activity credit, and they do show up in Sessions & IP as a second device or IP. A refusal LinkedIn hands you in its own UI is never logged here: a block you provoke by hand surfaces only later, if one of the platform’s own actions against that same target is refused and the two-browser check runs. Profile completeness is scored backend-side over twelve weighted fields:experience (8), about
(5), educations (4), location and skills (3 each), full_name, position, headline,
company_name, email and top_voices (2 each), supported_languages (1). List fields earn full
weight for one entry and 1.5x for two or more. The drawer’s short version: headline, photo, experience.
Snapshots
A snapshot is one immutable row holding everything health knew about the account at one moment: the score and itswarmup_breakdown ([{driver, penalty_pct}], unhealthy signals only), the profile summary, the
session list, 30-day counters, executed_actions per bucket, and the limit surface frozen as it stood.
Capture runs on a 1320 minute (22 hour) cadence; a freshly connected account gets its first one
during onboarding, not 22 hours later.
- Append only, and the score moves only when a snapshot lands. Roughly 33 rows a month per account, kept indefinitely, with no create, update or delete through the API. Fix a signal now and the number changes at the next capture, not immediately.
- Capture needs the account’s browser, since it reads the account’s own profile and session list. A browser stuck in an issue state stops producing new snapshots (browser troubleshooting).
- Its reads are charged to
self_account_sync, the one budget the warmup ramp never scales down, so health keeps capturing even on an account whose outreach budgets are tiny. - Two derived fields are worth knowing:
sessions_diff, the 0 to 100 change in the source IP set versus the previous snapshot, andaccount_diff, 0 to 100 weighted profile drift versus it, where 0 means unchanged.
Limit-hit log: LinkedIn refused us
One row records a LinkedIn rate-limit the background sync engine ran into. Only the connection-request and message surfaces write a typed row; a rate-limit on any other surface flips the browser to an issue status instead. Rows are immutable and never deleted. Each reason carries its own temporary lock on the matching budget.
A repeat refusal within 12 hours for the same account and reason re-arms the lock but writes no
second row, so the 30-day count behind Limit hits is not inflated by one bad hour. Every row freezes
the volume at the moment LinkedIn struck:
daily_count (since local start of day), seven_days_count,
thirty_days_count, and linkedin_quota_hit_till, when the lock expires. The daily_count spread across
rows answers “at what volume does this account get refused”.
Block log: a prospect blocked us
One row records the first time a target profile was found to be blocking one of your accounts. Rows are immutable and never deleted. LinkedIn publishes no “you were blocked” signal, so the platform derives it with a two-browser check: an outbound action fails with a LinkedIn 4xx refusal (429 rate limits excluded, they mean something else), the target is read through the affected account’s own browser, and if that fails it is retried through another live browser on your team. The triggering actions are connection requests, messages, InMails, sales messages, voice messages, skill endorsements, post likes, post comments and profile visits. Follow and unfollow are deliberately excluded, a follow refusal having too many innocent causes.
Deduplication is permanent, on the pair of account and target member id: one row per genuinely new
hostile target, forever, because a LinkedIn block does not expire. We cannot tell “blocked” from
“reported”, since both surface to us as the same opaque refusal, so the row carries no discriminator. If
the target was a connection, the same transaction soft-deletes that connection with
removal_kind set to
blocked and links it to the block row; soft-deleted connections stay queryable, and no conversation
and no message history is touched. Counts ride search results and the account include (total_count,
last_24h_count, last_7d_count, last_30d_count, with_connection_count, without_connection_count),
where the with-connection share says whether blocks burn existing relationships or cold prospects.
Checking one target on demand
check-target-block runs the same two-browser check synchronously for a target you name. The first
read spends 1 unit of the account’s self_account_sync budget. A target already in the block log
and a skipped_recent result inside the 24 hour cache spend nothing. "force": true bypasses the
cache.
curl
422 validation_failed, when target carries none of ln_id, sn_id or
nickname. A team with no second browser free is not an error: it answers 200 with
outcome: "inconclusive", nothing having run on a second browser.
How health drives smart limits
On each snapshot the score becomes a ceiling per action type (platform_max[bucket] * warmup_score / 100,
rounded) and the budget walks toward it. Up is gradual, down is instant: each recompute moves a budget up
by about 20% of the gap to its ceiling (at least 1), so recovery takes several snapshots, while a
ceiling below the current budget drops it at once, and the upward step is held while the bucket’s
LinkedIn lock (linkedin_quota_hit_till) is still in the future, that is after a recent limit hit or a
captcha. A prospect block holds nothing directly; it lowers the next snapshot’s score, and the score
lowers the ceiling. A fresh refusal does not recompute budgets by itself: it writes the temporary
lock, and its presence in the 30-day count changes the score at the next capture. Your own configured
ceiling is never touched. Budgets, holds, the active / held / linkedin_blocked statuses and how to
release a hold are in smart limits and warmup.
Reading a state and acting on it
Reading health over the API
All three records are read-only and searchable at/api/linkedin-account-snapshots/search,
/api/linkedin-account-quota-hits/search (filter linkedin_quota_hit_till with gt now for live locks
only) and /api/linkedin-account-block-log/search; shapes are in the
API reference. Includes on the account are cheaper than three searches:
linkedin_account_snapshot returns the latest snapshot, linkedin_account_quota_hits and
linkedin_account_block_log the last 50 rows each, newest first.