---
title: "Update an account's safety policy"
method: PATCH
path: "/v1/{account_id}/safety-policy"
tags: ["Safety"]
---

# Update an account's safety policy

`PATCH /v1/{account_id}/safety-policy`

## Request

- Base URL: `https://api.curviate.com`
- URL: `https://api.curviate.com/v1/{account_id}/safety-policy`
- Auth: HTTP bearer

Updates this account's safety policy. Send only the fields you're changing; null clears an override back to the tenant or Curviate default. Any value is accepted, none are clamped. Fields, posture and precedence: /reference/safety.

## Path parameters

- `account_id` string, required

## Request body

- object
  - `limit_profile` 'basic' | 'premium' | 'sales_navigator' | 'recruiter': Which set of Curviate defaults this account's rows resolve from: `basic`, `premium`, `sales_navigator` or `recruiter`. Each inherits the one before it and raises a few rows. This is the platform product active on the account, NOT the Curviate seat entitlement, and the two can disagree: a lapsed subscription leaves the seat untouched. Curviate detects it from the platform when the account connects, re-detects it on every reconnect and, at most once a day, on an account read; setting it here pins it, and the pin wins over detection from then on. None of those paths moves a pinned value, so it holds until you release it. To release the pin, send `limit_profile_source: "default"` on its own: Curviate re-reads the value from LinkedIn on the account's next read or connect. Changing `limit_profile` and releasing it in the same request is refused with `INVALID_REQUEST`, because those are opposite intents. Sending the value the account already holds changes nothing and pins nothing. The change is recorded in the ledger and it moves the defaults under every row at once, so any row you have configured explicitly keeps your value. The elevated Sales Navigator and Recruiter figures apply only to calls made through the matching interface, the Sales Navigator and Recruiter operations, and Curviate holds each call to the figure for the interface it goes through. The numbers here are therefore the ones a standard-interface call is held to, on every profile; a call through one of those operations resolves from the elevated set instead, and each row publishes that figure as `interface_ceiling`.
  - `timezone` string, nullable: The IANA zone this account's ACTIVITY window is read in, e.g. `Europe/Berlin`. Must be a real zone; anything else is a 400. Null clears it, which DISABLES the activity window for every row that has not set its own: a guessed zone inverts the window, which is worse than not having one.
  - `tenant_default_posture` 'warn' | 'enforce', nullable: The tenant-wide posture default, which every account inherits unless it overrides it. Set it once for a whole tenant of personas. Null clears it back to `warn`.
  - `clear_halts` string[]: Lift the platform pause on these action types now, instead of waiting for it to expire. When the platform refuses a call on an account (a rate limit, a server error on a budgeted route, an invitation resend cooldown), Curviate pauses that action type for the platform's own retry-after or a seeded cooldown, and every later call on it is refused locally without reaching the platform. Naming an action type here clears that pause: the next call goes out. Read `quotas[].halt` on the account to see which action types are paused and for how long. A type with no active pause is accepted and does nothing, so this is safe to send for a set you have not checked. Clearing is recorded in the account's ledger with the expiry it cut short, who cleared it and when, and it appears on the safety-events surface. It does not disarm anything: if the platform refuses the account again, the pause comes straight back, longer than the one you cleared where the platform says so. Clear a pause when you have reason to believe the refusal was a transient upstream fault rather than the platform pushing back on this account; clearing one and calling straight back into a real rate limit is itself how accounts get restricted.
  - `posture` 'warn' | 'enforce', nullable: This account's posture default, overriding the tenant default for this account only. Null clears the override.
  - `rows` object[]: Per-row configuration. Each entry names its `budget_row` and the fields to change. A row read back from the policy document may be sent here unchanged: its read-only fields (`effective_ceiling`, `posture_source`, `over_default`, `warm_up_state`) are accepted and ignored.
    - `budget_row` 'connection_requests_with_note' | 'connection_requests_no_note' | 'messages_first_degree' | 'inmail' | 'open_profile_inmail' | 'profile_views' | 'search' | 'comments' | 'post_likes' | 'follows' | 'content_poll' | 'job_poll' | 'posts' | 'endorsements' | 'invite_responses' | 'withdrawal_sweep' | 'total_actions' | 'pending_invites', required: The budget row this patch configures.
    - `ceiling` integer, nullable: What `enforce` refuses at and `warn` reports at, in this row's own unit. Any value up to 9007199254740991 is accepted and stored verbatim; a value above the Curviate default is reported back, never clamped.
    - `green` integer, nullable: Routine band. Activity at or below it is unremarkable.
    - `amber` integer, nullable: Warn-louder band, between green and the ceiling.
    - `window_kind` 'day' | 'week' | 'month', nullable: The calendar grain this row is counted in.
    - `window_span` integer, nullable: How many `window_kind` units one counting window spans.
    - `window_timezone` string, nullable: IANA zone name the counting window is aligned to, e.g. `Europe/Berlin` or `UTC`. Must be a real zone; anything else is a 400. Null counts in UTC.
    - `posture` 'warn' | 'enforce', nullable: Posture for this row alone, overriding the account default. Null inherits the account default, which inherits the tenant default.
    - `activity_window_start` string, nullable: Local time the activity window opens, HH:MM. Outside the window, live write operations are reported or refused per posture.
    - `activity_window_end` string, nullable: Local time the activity window closes, HH:MM.
    - `activity_window_timezone` string, nullable: IANA zone name the activity window is read in, e.g. `Europe/Berlin`. Must be a real zone; anything else is a 400. Null DISABLES the activity window: a guessed zone inverts it, which is worse than not having one.
    - `activity_window_applies_to` 'writes' | 'all', nullable: What the activity window governs. `writes` (the default) covers live writes to LinkedIn; `all` widens it to live reads. A read Curviate answers from its own store never reaches LinkedIn and so is never covered; the same read IS covered when the store cannot answer it, or when you ask for it live. Stored reads and inbound events are never subject to the window under either setting, at any hour. One action set to `all` reaches the whole account's live reads that spend no budget of their own (notifications, profile analytics, SSI, profile visitors, the invitation and saved-list listings): those calls debit no action type, so they have none to read this setting from and take the account-wide answer instead. Reads that DO debit an action type read that action type's own setting.
    - `warm_up_state` 'none' | 'ramping' | 'restriction_recovery', nullable: READ-ONLY. The warm-up ramp state active on this row: the label for `warm_up_factor` beside it, derived from it, so the two always agree. A value sent here is still checked for being a state at all, and is then accepted and ignored: it is never stored and never recorded in the ledger. Set `warm_up_factor` to pin a row out of the ramp Curviate derives for the account.
    - `warm_up_factor` number, nullable: Multiplier applied to this row's ceiling while ramping. 1 is not ramping.
    - `character_cap` integer, nullable: Length cap on this row's free-text field, in characters (an invitation note, an InMail body).
    - `subject_character_cap` integer, nullable: Length cap on this row's subject line, in characters.
    - `per_query_cap` integer, nullable: Bound on a single query's result lines. Search rows only.
    - `source_class` 'substrate' | 'linkedin_official' | 'practitioner' | 'inferred' | 'unseeded', nullable: How well calibrated this row's configured number is. Overriding it labels your own figure; the over-default warning always reports the class of the Curviate default.
    - `effective_ceiling` unknown
    - `interface_ceiling` unknown
    - `effective_interface_ceiling` unknown
    - `posture_source` unknown
    - `over_default` unknown
  - `object` unknown
  - `account_id` unknown
  - `limit_profile_source` 'default' | 'detected' | 'operator': Where `limit_profile` came from: `default` (never observed), `detected` (read from LinkedIn) or `operator` (pinned through this operation, and nothing but a release moves it). Sending `default` releases a pin: the value is re-read from LinkedIn on the account's next read or connect. Send it on its own; changing `limit_profile` in the same request is refused. Sending `detected` or `operator` is accepted and ignored, so the whole document round-trips.
  - `limit_profile_detected_at` unknown
  - `posture_source` unknown
  - `warm_up` unknown
  - `activity_window` unknown

## Response `200`

The account's full safety policy: every configurable field for every budget row, in one response. Any row whose configured value is above the Curviate default carries an `over_default` entry naming the value, the default and the source class of that default. Two top-level fields are DERIVED rather than configured: `warm_up` says whether Curviate is currently holding this account below its configured ceilings and by how much, and `activity_window` says which timezone the window is read in and whether it is switched on at all. This is the same body the read returns, so a value set above default is reported on the write that set it.

- object
  - `object` 'safety_policy': Response type discriminator.
  - `account_id` string: The account this policy governs.
  - `limit_profile` 'basic' | 'premium' | 'sales_navigator' | 'recruiter': Which set of Curviate defaults every row below is resolved from. Each profile inherits the one before it and raises a few rows: `premium` adds a daily invitation band and open-profile InMail, `sales_navigator` raises profile views and search, `recruiter` raises profile views again. This is the platform product active on the account, not the Curviate seat entitlement. The two can disagree, and this is the one that governs the numbers. 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, and Curviate holds each call to the figure for the interface it goes through. The numbers here are therefore the ones a standard-interface call is held to, on every profile; a call through one of those operations resolves from the elevated set instead, and each row publishes that figure as `interface_ceiling`.
  - `limit_profile_source` 'default' | 'detected' | 'operator': Where `limit_profile` came from. `detected`: Curviate read it from LinkedIn, at `limit_profile_detected_at`. `operator`: it was set through this operation and holds until you release it by sending `limit_profile_source: "default"` on this operation, on its own (changing `limit_profile` and releasing it in the same request is refused). Nothing else overrides it: a connect, a reconnect and an account read all leave a pinned value exactly as you set it. `default`: it has never been observed, so the value is the seeded `basic` and says nothing about the account; Curviate detects it on the account's next read or connect.
  - `limit_profile_detected_at` string, date-time, nullable: When Curviate last read `limit_profile` from LinkedIn, whether or not the value changed. Null until it has. Curviate re-reads it on connect and, at most once a day, on an account read, so a subscription change reaches the numbers without a reconnect. While `limit_profile_source` is `operator` neither the value nor this timestamp moves, until you release the pin.
  - `posture` 'warn' | 'enforce': The account's default posture, before any per-row override.
  - `posture_source` 'account' | 'tenant' | 'default': Whether that default is the account's own, the tenant's, or the built-in `warn`.
  - `tenant_default_posture` 'warn' | 'enforce' | 'null', nullable: The workspace-wide posture default every account in the tenant inherits, or null when none is set. Always present, and separate from `posture` on purpose: when this account overrides the tenant default, a change to the tenant default does not move `posture`, and this field is how you can still see it took effect.
  - `warm_up` object: The account's ACTIVE warm-up ramp, derived on every read. While an account is warming up, every row's effective ceiling is its configured ceiling multiplied by `factor`, so a `factor` of 0.15 means an account is being held to 15 percent of the numbers the rows below report. This is what Curviate currently believes about the account, and it is separate from the per-row `warm_up_state` and `warm_up_factor` fields, which report what is active on that row: set `warm_up_factor` on a row to pin it and ignore the derivation, and read `warm_up_state` as the label for whichever factor won. Every warm-up number is inferred, and none of them is published by LinkedIn.
    - `state` 'none' | 'ramping' | 'restriction_recovery': `none` is an account at full speed. `ramping` is a new, thinly connected or dormant account climbing 0.15 to 0.35 to 0.60 to 0.85 to 1.00 by week. `restriction_recovery` is an account under, or just out of, a LinkedIn restriction: it holds at 0 for seven days after the restriction lifts, resumes at 0.40, then steps up 0.15 every four days.
    - `factor` number: The multiplier applied to every row's ceiling. 1 is not ramping.
    - `week` integer, nullable: Which week of the ramp this account is in, counted from whichever is later of its creation and its last return from a dormancy. Null when the post-restriction schedule applies instead, which is not the weekly table.
    - `triggers` string[]: The conditions currently true of this account: `account_age` under 180 days, `connections` under 150, `dormant` for over 30 days, `post_restriction` under or recovering from a LinkedIn restriction. `dormant` stays reported until the ramp it started completes, because that is what explains the factor.
    - `unevaluated` string[]: The conditions Curviate could not check because nothing supplies their input. Reported rather than assumed false: `connections` is here on every account today, because the connected-account record LinkedIn returns carries no connection count. An unevaluated trigger is not a trigger that was checked and found false.
  - `activity_window` object: The account-level half of the activity window. Outside the window, a live write to LinkedIn is reported under `warn` posture and refused under `enforce`; reads and anything served from Curviate's own store are never affected, at any hour.
    - `timezone` string, nullable: The IANA zone this account's activity window is read in, or null when none is set. A row may override it with its own `activity_window_timezone`.
    - `timezone_unset` boolean: True when no zone is set, which DISABLES the activity window for every row that has not set its own. Curviate never guesses a zone: a guess eight hours wrong inverts the window, refusing every daytime write and permitting every nocturnal one, which is worse than having no window at all. If you configured a window and it never fires, this is why.
  - `rows` object[]: Every budget row, always.
    - `budget_row` string: The counted-action row this policy governs.
    - `ceiling` integer, nullable: What `enforce` refuses at and `warn` reports at, in the row's own unit. Null means the row is uncalibrated: Curviate has no figure for it.
    - `green` integer, nullable: Routine band.
    - `amber` integer, nullable: Warn-louder band.
    - `window_kind` 'day' | 'week' | 'month': The calendar grain this row is counted in.
    - `window_span` integer: How many grain units one counting window spans.
    - `window_timezone` string, nullable: IANA zone name the counting window is aligned to. Null counts in UTC.
    - `posture` 'warn' | 'enforce': The posture in force for this row, after inheritance.
    - `posture_source` 'row' | 'account' | 'tenant' | 'default': Where that posture came from, so an override is visible as one.
    - `activity_window_start` string: Local HH:MM the activity window opens.
    - `activity_window_end` string: Local HH:MM the activity window closes.
    - `activity_window_timezone` string, nullable: IANA zone name the activity window is read in. Null disables the window entirely.
    - `activity_window_applies_to` 'writes' | 'all': What the activity window governs. `writes` (the default) covers live writes to LinkedIn; `all` widens it to live reads as well, which is the setting for a persona that should not be seen browsing at 03:00. It is broader than it sounds: `all` covers every live read, including the ones about the account itself, so a nightly analytics or invitation-backlog poll is refused at 03:00 too. A read Curviate answers from its own store never reaches LinkedIn, so it is never subject to the window under either setting, at any hour; the same read IS covered when the store cannot answer it, or when you ask for it live. Inbound events are never subject to it either. One action set to `all` reaches the whole account's live reads that spend no budget of their own (notifications, profile analytics, SSI, profile visitors, the invitation and saved-list listings): those calls debit no action type, so they have none to read this setting from and take the account-wide answer instead. Reads that DO debit an action type read that action type's own setting.
    - `warm_up_state` 'none' | 'ramping' | 'restriction_recovery': The warm-up state ACTIVE on this row: the label for `warm_up_factor` beside it, so the two always agree. Read-only, so a value sent on a write is accepted and ignored; set `warm_up_factor` to pin this row's ramp.
    - `warm_up_factor` number: The multiplier ACTIVE on this row, which is what `effective_ceiling` above was computed with. 1 is not ramping. Set it to pin this row and ignore the account's derived ramp; leave it unset to follow the derivation.
    - `character_cap` integer, nullable: Length cap on the row's free-text field, in characters.
    - `subject_character_cap` integer, nullable: Length cap on the row's subject line, in characters.
    - `per_query_cap` integer, nullable: Bound on a single query's result lines. Search rows only.
    - `source_class` 'substrate' | 'linkedin_official' | 'practitioner' | 'inferred' | 'unseeded': How well calibrated this row's number is. `unseeded` means Curviate has no green, amber or ceiling figure for this row on this profile: the row is reported so nothing is silently missing, but nothing has calibrated it, so no value you set can be `over_default`.
    - `effective_ceiling` integer, nullable: What this row is ACTUALLY held to right now: `ceiling` with the account's warm-up ramp applied. Read this one, not `ceiling`, to know where the next call will be stopped. On an account in warm-up the two differ, and on a five-day-old account they differ by a factor of about seven. Null when the row has no ceiling to ramp. `warm_up_state` and `warm_up_factor` beside it are the values ACTIVE on this row, not the seeded ones.
    - `interface_ceiling` integer, nullable: What a call through the matching ELEVATED interface is held to on this row: the `/v1/{account_id}/sales-navigator/...` and `/v1/{account_id}/recruiter/...` operations. Null when this row and limit profile have no such figure, which is every row on `basic` and `premium` and most rows everywhere. Everything else on this row (`ceiling`, `effective_ceiling`, `band` and `over_default`) is the STANDARD-interface figure, because one counter serves both interfaces and the standard side is the conservative one to band against. The consequence is worth reading: on a `sales_navigator` or `recruiter` account a row can report `band: "over"` with `posture: "enforce"` while a call through the elevated interface still succeeds; the refusal the band predicts is the one a standard call gets.
    - `effective_interface_ceiling` integer, nullable: `interface_ceiling` with the account's warm-up ramp applied, and null wherever `interface_ceiling` is null. Read THIS to know where an elevated-interface call stops, for the same reason `effective_ceiling` rather than `ceiling` answers that for a standard one.
    - `over_default` object[]: Empty when nothing on this row is above default. Computed against the STANDARD-interface default, like `band` and for the same reason.
      - `field` 'ceiling' | 'green' | 'amber' | 'character_cap' | 'subject_character_cap' | 'per_query_cap': The field whose configured value is above default.
      - `value` integer: The configured value.
      - `default` integer: The Curviate default it exceeds.
      - `source_class` 'substrate' | 'linkedin_official' | 'practitioner' | 'inferred' | 'unseeded': How well calibrated the DEFAULT is. `substrate` and `linkedin_official` are published figures; `practitioner` is corroborated operator experience; `inferred` is our own reasoning; `unseeded` means we have no figure at all for that row, in which case there is nothing to be above and no entry appears. Read this before deciding whether being above default matters.
  - `safety_warning` SafetyWarning: Present on a successful response when this account crossed an account-safety ceiling while the affected budget row is on the default warn posture. The action still happened. Field-identical to the BUDGET_EXHAUSTED error body except that blocked is false and hint.message describes the outcome, so one branch handles both postures and switching the account to enforce is a configuration change rather than a client rewrite.
    - `row` string, nullable, required: The budget row that breached, for example profile_views or pending_invites. Null when the breach is not about a row: an activity_window warning on an action that spends no budget of its own (editing a message, dismissing a notification, a Recruiter or Sales Navigator admin action) is still governed by the hours the account works in. Read hint.parameter for what to change; whenever this is null it names posture, because the activity window edges are set per action type and this action has none, so the levers that reach it are the account timezone and posture.
    - `reset_at` string, nullable, required: The instant the refusal lifts: the window roll on a ceiling refusal, the next window open on an activity-window one. Null in the two cases no clock frees, the pending_invites gauge and InMail credits (row inmail).
    - `hint` object, required
      - `parameter` string, required: The settable parameter on PATCH /v1/{account_id}/safety-policy.
      - `message` string, required: What the condition is and what changing that parameter would do. This is the one field whose text differs from the BUDGET_EXHAUSTED refusal: here it says the action went through, and on a ceiling breach how far past the ceiling this row now stands, rather than telling you to set posture to warn when it already is. Branch on parameter, reason and blocked, never on this text.
    - `reason` 'ceiling' | 'activity_window', required: Which rule was crossed. ceiling means the row's configured limit is spent; activity_window means the account is outside the hours it works in, which is a different fact with a different remedy. hint.parameter names the right setting for each.
    - `blocked` boolean, required: Always false here. True on the BUDGET_EXHAUSTED refusal carrying the same payload.

## Other responses

- `400`: INVALID_REQUEST. The row name, posture or source class is not one of the accepted values, or a number is not a number (a negative ceiling). A value is never rejected for being unsafe: any limit may be set to any value, and a high one is reported rather than refused.
- `404`: No such account for this tenant. An id whose connection was replaced or removed also stops resolving here, and it is already absent from the list, so re-read `GET /v1/accounts` for the current id.
- `413`: Payload too large, the request body exceeds the size limit.
- `429`: Rate limited, slow down and retry after the hinted delay.

---

[API](https://skmtc.dev/curviate/apis/curviate-api.md) · [All operations](https://skmtc.dev/curviate/apis/curviate-api/llms.txt) · [OpenAPI document](https://skmtc.dev/curviate/apis/curviate-api/revisions/eab848801d95?raw)
