Account safety
Every connected LinkedIn account carries a set of ceilings on what it may do per day, per week and per month. They are configuration, not constants: you read them, you change them, and you decide whether crossing one warns you or stops the call.
Three account-scoped surfaces, plus the tenant defaults every account inherits:
| Operation | What it answers |
|---|---|
GET /v1/{account_id}/safety-policy | What is this account allowed, and what happens at the limit? |
PATCH /v1/{account_id}/safety-policy | Change any of it. |
GET /v1/{account_id}/safety-events | What has already been refused or flagged? |
GET / PATCH /v1/safety-policy | What does every account start from, per limit profile? |
The quotas array on GET /v1/accounts/{account_id} answers a fourth: where
does the account stand right now, against those limits. The full schemas, every
field and every example live in the interactive API reference
under Safety.
Nothing is refused by default. Every scope ships on the
warnposture, which lets the action through and reports the breach. Switching an account toenforceis a configuration change you make deliberately.
Reading the policy
curl https://api.curviate.com/v1/acc_01JQZK8N3XV4RTYWB2M6D5F0AC/safety-policy \
-H "Authorization: Bearer cvt_live_..."One request returns the whole policy: every row, plus the account-level fields that modify all of them.
{
"object": "safety_policy",
"account_id": "acc_01JQZK8N3XV4RTYWB2M6D5F0AC",
"limit_profile": "basic",
"posture": "warn",
"posture_source": "default",
"tenant_default_posture": null,
"warm_up": {
"state": "none",
"factor": 1,
"week": null,
"triggers": [],
"unevaluated": ["connections"]
},
"activity_window": { "timezone": null, "timezone_unset": true },
"rows": [
{
"budget_row": "profile_views",
"ceiling": 100,
"effective_ceiling": 100,
"green": 60,
"amber": 90,
"window_kind": "day",
"window_span": 1,
"window_timezone": null,
"posture": "warn",
"posture_source": "default",
"source_class": "substrate",
"over_default": [],
"activity_window_start": "07:00",
"activity_window_end": "22:00",
"activity_window_timezone": null,
"activity_window_applies_to": "writes",
"warm_up_state": "none",
"warm_up_factor": 1,
"character_cap": null,
"subject_character_cap": null,
"per_query_cap": null
}
]
}Rows
A row is one kind of action: profile_views, connection_requests_no_note,
inmail, search, comments, and so on. Each carries its own ceiling, its own
counting window and its own posture.
ceilingis what you configured.effective_ceilingis what the account is actually held to, and it is the one to compare a count against. The two differ while a warm-up ramp is in force.greenandamberare reporting thresholds, and the only thing they move isbandon the account resource'squotasview:usedat or pastgreenreadsamber, at or pastamberreadshard, and at or past the effective ceiling readsover. All three comparisons use the RAMPED numbers, so on an account in warm-up the bands move with everything else. They change nothing about the refusal itself: the breach payload is byte-identical whatever they are set to, and loweringamberto get a louder or earlier warning does nothing at all.window_kindandwindow_spanreport the counting window (day,weekormonth), counted in UTC. They are reported, not yet honoured: the counter is written at one grain per row, so setting a different window on one account is accepted and changes nothing today. Read them; do not plan around changing them.source_classsays how well calibrated our default is:substrate,linkedin_official,practitioner,inferred, orunseededwhere we have no figure at all. It is the difference between "you are above a number somebody published" and "you are above a number we guessed", and a warning without it is uniform and therefore ignorable.over_defaultis non-empty when you configured something above our own default. It carries the field, your value, our default and that default's source class, and it persists: a limit raised six months ago is still raised today and still says so. Set the value back and it disappears.
Two rows are not counters and behave differently: total_actions is a tally
across everything the account did in the window, and pending_invites is the
outstanding invitation backlog, a level read from LinkedIn rather than something
the account spends.
limit_profile
limit_profile is the LinkedIn product active on the account (basic,
premium, sales_navigator, recruiter) and it selects which set of Curviate
defaults every row resolves from. It is detected when the account connects,
re-detected on every reconnect and, at most once a day, when the account is
read; setting it explicitly overrides that until the next connect. The
document says where the value came from: limit_profile_source is detected
(read from LinkedIn at limit_profile_detected_at), operator (set through
this operation) or default (never observed, so the value is the seeded
basic and says nothing about the account). To release an override, send
limit_profile_source: "default" on the write; writing back the value the
account already holds changes nothing.
Each call is held to the figure for the interface it goes through. The elevated Sales Navigator and Recruiter figures apply only to calls made through the matching interface, the
/v1/{account_id}/sales-navigator/...and/v1/{account_id}/recruiter/...operations. Soceilingis the number an ordinary call is held to, on every profile, andinterface_ceilingbeside it is what a call through the elevated interface gets. Onrecruiter,profile_viewsreadsceiling: 100andinterface_ceiling: 2000; onsales_navigator,searchreadsceiling: 1000andinterface_ceiling: 2500. Both arenullwhere there is no such figure, which is every row onbasicandpremium.One consequence worth reading before you act on a band.
bandandover_defaultare computed againstceiling, the standard figure, because a single counter serves both interfaces and the standard side is the conservative one to measure against. So onsales_navigatororrecruitera row can reportband: "over"withposture: "enforce"while a call through the elevated interface still succeeds. The refusal the band predicts is the one an ordinary call gets.
posture
posture is what happens when a row crosses its ceiling.
| Value | Effect |
|---|---|
warn (the default) | The action goes through, and the successful response carries the breach under safety_warning. |
enforce | The action is refused before it reaches LinkedIn. Nothing is sent and nothing is spent. |
It resolves row, then account, then tenant, then warn, and
posture_source on each row and at the top level says which of the four
supplied the value, so an override is visible as an override rather than as a
coincidence.
tenant_default_posture is the tenant-wide default. It is written through
this same account-addressed operation, and it changes every account in the
tenant, not the one in the path. It is always reported at the top level, as
the raw tenant value or null, so a write that flips it is visible even on an
account holding its own override.
warm_up
A brand new account running at a mature account's numbers is the most reliable
way to get one restricted, so every ceiling is multiplied by a ramp factor while
the account is young, thinly connected, dormant, or under or recovering from a
restriction. A restriction still in force is factor 0, which is a hold
rather than a ramp: under enforce every row's effective ceiling is 0 and
nothing goes out at all, and under the default warn every action carries a
warning instead.
{
"state": "ramping",
"factor": 0.15,
"week": 0,
"triggers": ["account_age"],
"unevaluated": ["connections"]
}- The ramp is
0.15in week 0, then0.35,0.60,0.85,1.00. After a restriction lifts it holds at0for 7 days, resumes at0.40and steps up0.15every 4 days. warm_upis read-only and derived on every read. To ignore it for one kind of action, set that row'swarm_up_factorand your value wins.- A dormant account restarts its ramp when it comes back. The week counts
from whichever is later of the account's creation and its last return from a
gap, so a two-year-old persona back after six months of silence starts at
0.15rather than at full speed. unevaluatedis not "false".connectionsis in it on every account today, because the connected-account record LinkedIn returns carries no connection count. A trigger we could not check is reported, not assumed away.- Every warm-up figure is
inferred. Nobody publishes a ramp.
effective_ceiling on each row is ceiling with this factor already applied.
On a five-day-old account the two differ by roughly a factor of seven, and the
smaller one is where your call is stopped.
activity_window
The hours the account works in, seeded 07:00 to 22:00, with both edges varied by up to 45 minutes per account per day so the schedule does not read as a timetable. Outside it, a live write to LinkedIn warns or is refused, exactly as a spent ceiling is.
The window needs a timezone, and the zone resolves row, then account, then
unset. A row's own activity_window_timezone wins; with none, the account's
timezone applies; with neither, the window is disabled for that row and never
fires at any hour. Curviate does not infer the zone, deliberately: a zone eight
hours wrong inverts the window, refusing every daytime write and permitting
every nocturnal one, which is worse than not having one.
activity_window.timezone_unset reports the ACCOUNT rung only. It is
true whenever the account has no timezone, including when a row carries its
own and is enforcing normally. So timezone_unset: true does not mean "the
window is off everywhere". If you are debugging a refusal at 03:00, check the
row's activity_window_timezone before concluding the window cannot be the
cause.
Set the account timezone. That is the switch that works for every row,
and it is what the window is designed around. A per-row zone exists, but the
window is evaluated once per call from the FIRST budget row that call would
debit, so on an operation touching more than one row a per-row zone on the
others is not consulted. Use it to carve out a single-row exception, not as
the way to turn the window on.
curl -X PATCH https://api.curviate.com/v1/acc_01JQZK8N3XV4RTYWB2M6D5F0AC/safety-policy \
-H "Authorization: Bearer cvt_live_..." \
-H "Content-Type: application/json" \
-d '{"timezone": "Europe/Berlin"}'activity_window_applies_to chooses what the window covers: writes (the
default) or all, which widens it to live reads as well. all is broader than
it sounds. It covers every live read, including the ones about the account
itself, so a nightly analytics or backlog poll is refused at 03:00 too. Anything
Curviate serves from its own store, and every inbound event, is unaffected at
any hour under either setting.
The field is set per action type, and one of its answers is account-wide. The
live reads about the account itself spend no budget of their own, so they have
no action type to read this setting from and take the account's answer instead:
setting all on any one action type governs every one of them. A read that does
debit an action type reads that action type's own setting, so an account can
hold all on one action type and writes on the rest while those
account-level reads are governed. The policy document reports the field per
action type only, so it does not yet show the account-wide answer back to you.
Changing the policy
One PATCH, partial, one row or many:
curl -X PATCH https://api.curviate.com/v1/acc_01JQZK8N3XV4RTYWB2M6D5F0AC/safety-policy \
-H "Authorization: Bearer cvt_live_..." \
-H "Content-Type: application/json" \
-d '{"rows": [{"budget_row": "profile_views", "ceiling": 100000}]}'Curviate never clamps a value. Any limit may be set to anything; a value above our default is a loud warning on every response that carries the policy, never a refusal. An agent may make these changes on the same footing as a human operator, and every change is written to the account's ledger with the actor who made it.
Sending the document back
Reading the policy and sending it back with one field edited is the obvious way to use a partial-update API, and it works. Three things make it safe, and one of them is a rule you have to follow:
- A value that does not move is not a change. A ceiling, band, window or cap sent at the value it already resolves to is neither stored as an override nor ledgered, so a round trip does not pin every field against a future corrected default.
- Derived fields are accepted and dropped.
effective_ceiling,posture_source,over_default,warm_upand each row'swarm_up_stateare computed on every read. Sending them back changes nothing. - Send the activity-window zone as the top-level
timezone, not as theactivity_windowobject. The read reports the zone underactivity_window; the field that SETS it istimezone. Sending the object is rejected withINVALID_REQUESTrather than accepted and quietly ignored, because silently swallowing an edit of the zone is worse than a400that names the field. - Posture is an OVERRIDE at every scope, so writing back a posture you merely
inherit PINS it. That is a real change and it is ledgered: the override is
what stops a later tenant-wide flip from moving that scope. If you did not
mean to pin it, omit
posturefrom the document you send back.
Tenant defaults
Set a limit once for every account on a limit profile, including accounts you connect later and before you connect the first one:
curl -X PATCH https://api.curviate.com/v1/safety-policy \
-H "Authorization: Bearer cvt_live_..." \
-H "Content-Type: application/json" \
-d '{"profiles": [{"limit_profile": "basic", "rows": [{"budget_row": "connection_requests_no_note", "ceiling": 50}]}]}'Each field resolves from the account's own value where it set one, otherwise the
tenant default for the account's limit_profile, otherwise the Curviate
default. null clears a tenant default. The change applies immediately to
every account that has not set that field itself, so under enforce lowering a
default can start refusing actions across the tenant in one request. The
response carries impact: accounts_affected (accounts whose ceiling changed)
and accounts_moved_into_over, measured on those accounts before and after the
write. Row posture is not a tenant default; use tenant_default_posture.
What a breach looks like
The same payload in both directions. It differs by exactly one field you branch
on, blocked, and by the wording of hint.message, which describes what happened.
Under warn: on the successful response
{
"object": "user",
"id": "...",
"safety_warning": {
"row": "profile_views",
"reset_at": "2026-09-06T00:00:00.000Z",
"hint": {
"parameter": "profile_views.ceiling",
"message": "This account is at its ceiling for profile_views. This account is in warm-up, so the number it was stopped at is not the number on the row: effective ceiling 15 = 100 x warm-up 0.15 (week 0, account_age). Raising the ceiling raises the effective ceiling by the same factor. This action went through because profile_views.posture is warn, and it counted: 16 of 15 this window. Raise profile_views.ceiling, or wait for the window to reset at 2026-09-06T00:00:00.000Z."
},
"reason": "ceiling",
"blocked": false
}
}Under enforce: 429 BUDGET_EXHAUSTED
{
"code": "BUDGET_EXHAUSTED",
"message": "The profile_views budget for this account is spent.",
"row": "profile_views",
"reset_at": "2026-09-06T00:00:00.000Z",
"hint": {
"parameter": "profile_views.ceiling",
"message": "This account is at its ceiling for profile_views. This account is in warm-up, so the number it was stopped at is not the number on the row: effective ceiling 15 = 100 x warm-up 0.15 (week 0, account_age). Raising the ceiling raises the effective ceiling by the same factor. Wait for the window to reset at 2026-09-06T00:00:00.000Z, raise profile_views.ceiling, or set profile_views.posture to warn to let the action through."
},
"reason": "ceiling",
"blocked": true
}row, reset_at, hint.parameter and reason are identical; only blocked
differs, plus the prose in hint.message: the refusal names posture as the
lever that lets the action through, the warning says the action went through
and, on a ceiling breach, how far past the ceiling the row now stands. One
branch of your code handles both, and moving an account to enforce is a
configuration change rather than a client rewrite. Never branch on the message.
BUDGET_EXHAUSTED is not a rate limit, even though both are 429.
PLATFORM_RATE_LIMIT means LinkedIn refused; this means Curviate did, on a
number you set. Back off for the first. For this one, wait until reset_at
when there is one, and otherwise change hint.parameter. There is deliberately no Retry-After header and no
delay retry hint: a monthly row's reset can be weeks out, and a client that
honoured the header would sleep it.
| Field | What to do with it |
|---|---|
row | Which budget row. On the invitation backlog it is the row that blocked, not the row you were spending. |
reason | ceiling (the limit is spent) or activity_window (the account is outside its hours). Different fixes, so branch on this. |
reset_at | The instant the refusal lifts. It is set on a ceiling refusal (the window roll) and on an activity-window refusal (the next window open). It is null in two cases, where no clock frees the account: the pending-invitations gauge (row: pending_invites) and InMail credits (row: inmail). Null-check it before handing it to a scheduler; reason and hint.parameter say what to do instead. |
hint.parameter | Addressable on PATCH /v1/{account_id}/safety-policy, so reconfiguring needs no prose parsing. |
blocked | true on the refusal, false on the warning. |
A few things worth knowing about specific rows:
- InMail has two bounds and both apply. Credit exhaustion is read from the
account's live LinkedIn balance, never from a local count, because a reply
within 90 days refunds the credit and a count is wrong exactly when it
matters; it is checked first, and its hint names
posture, because no ceiling change buys a credit. The send RATE is an ordinary ceiling on theinmailrow like any other, counted locally, with the hint naminginmail.ceiling. On thebasicprofile that ceiling is seeded0, becausebasicgets no InMail allowance, so an account holding credits there warns on every send until you raise the ceiling or move the profile. pending_invitesis a backlog level, not a budget you draw down. At its ceiling it blocks both invitation rows even with weekly quota left, and withdrawing invitations does not give that quota back. Its ownpostureis reported but is not the one that acts: the row blocks invitations rather than being spent itself, so whether a full backlog warns or refuses is the posture on the INVITATION row. Settingenforceon invitations is enough.total_actionsrefuses at no value under any posture. It is a tally, and LinkedIn's own limits are per action type and do not accumulate.- A refusal debits nothing. The counter does not move, and the attempt is recorded as refused.
When LinkedIn pauses a row
BUDGET_EXHAUSTED is a number you set. The other pause is not yours at all:
when LinkedIn refuses a call on an account, Curviate holds that action type for
LinkedIn's own retry-after or a seeded cooldown, and every later call on it is
refused locally with PLATFORM_RATE_LIMIT without leaving the process. The
account resource reports the live ones:
{
"budget_row": "profile_views",
"used": 41,
"halt": { "reason": "platform_fault", "retry_after_seconds": 214 }
}Retrying into a real rate limit is how accounts get restricted, so the pause is the product behaviour rather than an inconvenience. It is not permanent, and it is not a state you have to wait out blind:
- It expires on its own, and
retry_after_secondsis the only number to act on. It is LinkedIn's own retry-after where one was supplied, and a Curviate default otherwise. Do not assume every pause is the same length: a server error on a READ gets a much shorter default than a rate limit does, because a500on a read is far more often an upstream blip than LinkedIn pushing back. A429, and any refused write, keeps the long one. - You can lift one early, on the same
PATCHas everything else:
curl -X PATCH https://api.curviate.com/v1/acc_01JQZK8N3XV4RTYWB2M6D5F0AC/safety-policy \
-H "Authorization: Bearer cvt_live_..." \
-H "Content-Type: application/json" \
-d '{"clear_halts": ["profile_views"]}'The next call goes out. An action type with no active pause is accepted and
does nothing, so it is safe to send for a set you have not checked; only the
ones actually lifted are recorded, each with the expiry it cut short, who
lifted it and when, and each appears on the events surface below with
reason: "halt_cleared". Lifting a pause disarms nothing: if LinkedIn
refuses the account again, the pause comes straight back. Lift one when you
have reason to believe the refusal was a transient upstream fault. Lifting one
and calling straight back into a real rate limit is the thing this whole
mechanism exists to stop you doing.
What already happened
safety_warning on one response is a warning nobody reads twice. The events
surface is the durable record, newest first:
curl "https://api.curviate.com/v1/acc_01JQZK8N3XV4RTYWB2M6D5F0AC/safety-events?budget_row=profile_views" \
-H "Authorization: Bearer cvt_live_..."{
"object": "safety_event_list",
"items": [
{
"object": "safety_event",
"id": "aln_01JQZ8T7K3M9V2X4B6C8D0E1F2",
"budget_row": "profile_views",
"reason": "budget_exhausted",
"blocked": false,
"method": "getUserProfile",
"occurred_at": "2026-09-04T09:14:02.000Z"
}
],
"cursor": null
}blocked: falseis the common case, not an anomaly. On the default posture the action went through, and the event is the only lasting record that it crossed a limit.reasonhas four values and each needs a different response.budget_exhaustedis a ceiling you configured and can raise;activity_windowis the account acting outside its working hours, where no ceiling is the constraint, so wait for the window or move its edges;rate_limitedis LinkedIn having refused this account on that row recently, which no ceiling you raise lifts, thoughclear_haltslifts it deliberately. The refusal ERROR spells the same rulesceilingandactivity_window, so do not reuse one mapping across both:ceilingthere isbudget_exhaustedhere.halt_clearedis the one entry that is not a refusal. It records arate_limitedpause being lifted early withclear_halts, so the row was open again from that instant. It carriesblocked: falseand itsmethodisupdate_safety_policy. Read it beside therate_limitedentry it lifts, or you will report a row as paused that is not.- Filter with
budget_row,sinceanduntil; page untilcursorisnull. - The field is
budget_rowhere, androwon the error envelope. Same fact, and every read surface in the product spells itbudget_row.
This surface and quotas never disagree, because they read the same
records for different questions: quotas is where the account stands right
now, and this is what already happened.
Next steps
- Rate limits: the request-rate ceilings, which are a different thing from these and are always enforced.
- Errors: the full error-code reference, including
BUDGET_EXHAUSTED. - Open the API Reference: every field, every example, under Safety.