> ## Documentation Index
> Fetch the complete documentation index at: https://docs.gtm-api.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Changelog

> What's new in the GTM API platform and this documentation.

<Update label="2026-10-07" description="Your calls no longer wait on the account's own sync">
  **Your calls have a budget of their own.** Each LinkedIn account takes up to 30 of your calls
  a minute and 5 at once. The platform's own work on the account (its syncs, checks and jobs)
  runs on a separate budget and no longer counts against yours. Both used to share 10 a minute
  and 3 at once, so a sync catching up could refuse a send with `429 rate_limited` and
  `reason: "account_rate_exceeded"`. That refusal now carries `retry_after`, the moment a place
  frees, so wait for it rather than guess.

  **Inbox reads have room.** The daily `self_account_sync` budget that every head refresh and
  every background message pull spends is 1000 calls, up from 200. A busy integration polling
  its inbox no longer spends it for the day and stops the account's sync with it.

  **A waiting sync no longer refuses a head refresh.** `get-my-latest` used to answer
  `sync_in_progress` for as long as a run of the same kind existed, even one that only waited for
  its sync window or its browser, for hours. Now only a run that is reading counts, and a head
  refresh of one thread never waits for the inbox sync.
  [Inbox and message sync](/kb/inbox-and-message-sync) has the details.
</Update>

<Update label="2026-10-06" description="Only accounts signed out for 30 days lose their browser profile">
  **A Sandbox account that stays signed in to LinkedIn is never parked.** The 30-day sweep used to
  park an account with no user-driven activity, even one that was signed in and synced every day.
  Now it parks only an account LinkedIn has logged out or restricted, and only after 30 days in
  that state. The browser profile of such an account is deleted, so coming back means connecting
  it and logging into LinkedIn again.

  **Held profiles follow the same rule.** After a downgrade, an account parked over the plan's
  limit used to lose its browser profile on day 28 of the hold. Now the profile is deleted only
  once LinkedIn has logged the account out or restricted it for more than 30 days, with the
  warning email a week before. A parked account that stays signed in waits for the upgrade.
  [Billing and plans](/kb/billing-and-plans) has the details.
</Update>

<Update label="2026-10-05" description="Invitations, comments, posts and reposts are sent once">
  **Every LinkedIn send is sent once now.** Connection requests, comments, replies, posts and
  reposts follow the rule the message sends got earlier today. Give each one a
  `client_reference`: the key and its place are one send, so a repeat is answered with what the
  first send came to and never goes out twice. The place of an invitation is the person, of a
  comment the post (named by any of its urns), of a reply the comment it answers, of a post where
  it lands (your feed, a company page, a group), and of a repost the reposted post. A lost answer
  is a `409 send_outcome_unknown` with `send_decisive_at`, the moment it can no longer land, where
  it used to be a `503` that said to retry shortly, and the platform reads LinkedIn to settle it.

  **Ask without sending.** `POST /api/linkedin-connection-requests/check-sent` and
  `POST /api/linkedin-posting/check-sent` (`check_linkedin_connection_request_sent` and
  `check_linkedin_posting_sent` over MCP) answer what an invitation, a comment, a post or a
  repost came to, as the message check-sent does. All three name `send_decisive_at` for a send in
  doubt. Every one of these send endpoints takes a person's word, `confirmed_not_sent` or
  `confirmed_sent`.

  **What changes for a caller.** A comment repeated after `unknown` no longer posts a second one.
  `409 post_not_created` used to mean that nothing was published. It now means the post may be
  out, so repeat the same request after `retry_after`, never as a new post. A post into a group
  cannot be read back, so one in doubt waits for a person. A reaction stays outside the rule:
  LinkedIn keeps one per account and post, so sending it again changes nothing. In a mass action a
  comment step goes out under a key of its own, a connection request or a comment whose answer
  was lost is asked about before anything goes again, and the step log no longer carries
  `send_decisive_at`.

  **A webhook for a call that failed.** `linkedin-account-activity-log.task-failed` fires once for
  each call an account dispatched that ended failed, with `failure_source` and, on a send,
  `send_outcome`: `unknown` means the send may be out, so ask check-sent before sending it again.
  [Sending once](/concepts/sending-once) and [Receive webhooks](/guides/receive-webhooks) have the
  details.
</Update>

<Update label="2026-10-05" description="Mass actions: each message gets a key of its own">
  **A run's `client_reference` is now a tag.** A mass action sends each message under a key of its
  own, so a step that runs again or is retried is answered with what the first send came to and
  never goes out twice. A target's own `client_reference` is still used as given when the plan sends
  with that tool once. A `client_reference` T in a send step's `args` used to go out unchanged on
  every message of the run. Now each message carries `T:{item sid}:{step id}`, and a run with no key
  puts `{item sid}:{step id}` on the messages that carried none.

  **If you matched T exactly.** A search for T no longer finds the run's messages. Find them by each
  item's `created_object_sid`, or by their exact keys with `eq` or `in`. There is no prefix search on
  `client_reference`, and a webhook subscriber matches the `T:` prefix in its own code. Never create
  the run again because T matched nothing: that sends every message twice.

  **Sending once.** Every LinkedIn message send endpoint now answers a repeat of the same key to the
  same place with what the first send came to, names `error.context.send_outcome` on every error, and
  has `check-sent` (`check_linkedin_message_sent` over MCP) to ask without sending. A send step whose
  answer was lost waits with `wait_reason: send_outcome_unknown` and asks before anything goes again,
  and a retry by item `sid` can carry a person's word with `resend_unverified`.
  [Sending once](/concepts/sending-once) and [Run a mass action](/guides/run-a-mass-action) have the
  details.
</Update>

<Update label="2026-10-05" description="A browser nobody signs in on is deleted after 30 days">
  **An unused browser no longer holds a seat for good.** A browser takes a seat of the plan from
  the moment it is created. One that no LinkedIn account ever signed in on, a sign-in link nobody
  opened or a sign-in left half done, used to keep its seat, its proxy and its profile with no end.
  Now the platform deletes such a browser 30 days after the last chance anyone had to sign in on it:
  its creation, its last start, the last cloud-browser session opened on it, or the moment its last
  sign-in link expired, whichever came last. The seat and the proxy are freed. A profile you brought
  from your own GoLogin account stays in your GoLogin account; only the browser on our side goes.

  Browsers with a connected LinkedIn account are never deleted this way, even while the account is
  signed out. `create_antidetect_browser` says so in its description, and
  [Antidetect browsers and proxies](/kb/antidetect-browsers-and-proxies#browser-lifecycle) has the
  details.
</Update>

<Update label="2026-09-28" description="Recruiter: a seat that was never signed in is not a sign-out">
  **`recruiter_status` tells the truth about a seat that never signed in.** A Recruiter seat whose
  browser has never been signed in to Recruiter used to read `active` and then flip to `signed_out`
  and back on every check, with a "LinkedIn Recruiter signed your account out" email on each flip.
  Now the seat read is the verdict: a seat in LinkedIn's answer is `active`, a refused talent call
  is `signed_out`, and no seat in the answer on an account that never held one is `signed_out` with
  a new webhook `reason`, `session_unconfirmed`. That case gets its own email, **Sign in to LinkedIn
  Recruiter to switch it on**, instead of a sign-out notice. The account's page shows **Sign in to
  Recruiter** for such a seat, and the row's Recruiter mark wears the warn tone. Both Recruiter
  emails are now sent at most once a day per account.

  **The premium check no longer opens LinkedIn's contract chooser by itself.** From the platform's
  browser the chooser's list is refused by LinkedIn on every account so far, a live session
  included, so each attempt was a failed row on the sender's activity screen twice a day and the
  refusal was read as a lost session. `get_linkedin_account_my_recruiter_contracts` and
  `select_linkedin_account_recruiter_contract` stay, pass LinkedIn's answer through verbatim, and
  never change `recruiter_status` on a refusal; a Select that answers a seat marks the account
  `active`. The working way to a bound seat is the sign-in through the cloud browser, where
  LinkedIn shows its own chooser.

  **`recruiter_session_expires_at` is read from the account's own browser.** The clock used to come
  from the antidetect vendor's cookie store, which for a profile you connected from your own
  GoLogin account is not the browser's jar at all (nothing the platform runs writes to it). It is
  now read from the running browser first, so the clock is the one the Recruiter calls are served
  under.

  **The background premium check paces itself.** A full check is up to eight calls on the account
  against a cap of ten a minute; it used to fail on the cap and start over from the first call,
  which on a busy account filled every following minute with the same calls until the day's
  self-sync budget ran out. It now waits out the cap between calls instead.
</Update>

<Update label="2026-09-24" description="Post search: page_size stops at 50, and the API says why">
  **`scrape_linkedin_search_posts`.** `page_size` now takes 1..50 instead of 1..100, and a larger value
  is refused with 422 `page_size_above_linkedin_max` instead of being sent through. The bound is
  LinkedIn's, not ours: its content search opens every query with a cluster of 3 posts and serves at
  most 50 more per pagination request. Asked for more, it answers with an empty page, which the
  platform read as the end of the feed, so a `page_size` of 100 came back as 3 rows with
  `has_more: false` and no error attached. Ask for 50 or fewer and follow `paging.next_cursor`; a
  page of 50 fills. The small end is unchanged and worth knowing: `page_size` 1 or 2 returns the
  opening cluster of 3.

  **The end of the feed is LinkedIn's word.** The same tool used to take any page LinkedIn answered
  without posts as the end of the feed, which is how an answer the platform could not read (a page
  that rendered differently, an interstitial) came back as a completed empty result. Now a page ends
  the feed only when LinkedIn's own answer says the window is empty; anything else without posts is
  refused with 503 `linkedin_page_unreadable` and a `retry_after` a minute out, never returned as
  rows.
</Update>

<Update label="2026-09-22" description="A signed-out sender is checked before you are told: lost session or LinkedIn restriction">
  **Two red statuses, two emails.** When LinkedIn signs a connected account out, the platform now
  reads that member's public profile from another LinkedIn account of the fleet before sending
  anything. A profile that answers means a lost session: the browser stays `login_issue` and the
  **Your LinkedIn account was signed out** mail carries a **Profile check** line saying so. No
  profile for the member's stable id means LinkedIn restricted the account (or the holder closed
  it): the browser moves to the new `restricted` status and the mail is **LinkedIn restricted your
  account**, with LinkedIn's verification and appeal steps instead of a re-login. Both statuses
  are sticky, powered off and never auto-restarted; **Start** re-verifies the session for both.

  **What changes on the wire.** `antidetect_browsers.status` gains `restricted`; a dispatch on such
  a sender is refused with 409 `browser_account_restricted` (next to `browser_logged_out`); the
  browser event log gains the `account_restricted` row with the identities asked and LinkedIn's
  answer; the account search counts gain `restricted_count`, and `signed_out_count` now counts both
  red statuses; two webhook events, `antidetect-browsers.restricted` and
  `linkedin-accounts.restricted`, fire once per episode after the logged-out pair. See
  [browser troubleshooting](/kb/browser-troubleshooting#restricted-linkedin-took-the-account-away).
</Update>

<Update label="2026-09-22" description="The services page as one read and one write, and reposts through one tool">
  **Your "Providing services" page.** `get_linkedin_account_my_services` reads the connected account's
  own services page as its edit form (status, description, the offered services with their ids, the
  location and messaging flags, review visibility, pricing, the currencies and the 16 top-level
  service categories), or null when there is no page. `set_linkedin_account_my_services` is the one
  write: with no page yet it creates one (description and services required, LinkedIn's onboarding
  defaults for the rest), with a page it changes only what you send, `republish: true` puts an
  unpublished page back without touching it, and `unpublish: true` takes it down. Each write spends
  one `edit_profile` slot; the response carries the page's form as it was before the change.

  **`repost_linkedin_post`.** One tool for both kinds of repost: without `text` it is the plain repost
  (the feed's Repost button), with `text` it is a repost with your thoughts, which also takes
  `visibility`, `allowed_commenters_scope` and `mentions` the way `create_linkedin_post` does (sending
  those without a text is refused, not dropped). `post_urn` is the post being reposted as an activity,
  share or ugcPost urn; a group post cannot be reposted. The answer has one shape for both:
  `activity_urn` (what `delete_linkedin_post` takes to retract it), `post_urn`, `url`, `created_at`,
  `text`, `parent_post_urn`. Spends the `posting` bucket.
</Update>

<Update label="2026-09-22" description="Edit the whole profile in one call: About, top skills, photo, background, and a tool for positions">
  **`edit_linkedin_account_my_profile` edits the whole profile.** Next to the intro card it now takes
  `about` (the About text, 2600 characters; an empty string clears it), `top_skills` (up to five, as
  names or the `ProfileSkill:<n>` ids a previous save reported; an empty list removes them all),
  `photo` and `background` (one image each, as base64 or an https URL we download; PNG, JPEG, GIF or
  WEBP, typed by their bytes). Send only what changes: the intro card is read first and submitted
  whole, the About save is partial, each image replaces one picture. Every section is its own
  LinkedIn save, run in order (intro card, About, photo, background) and reported under
  `result.sections` with its own activity-log row; a section LinkedIn refuses stops the call there and
  the error names `failed_section` and `applied_sections`, so what landed before it stays. One call
  spends one `edit_profile` slot however many sections it saves.

  **`set_linkedin_account_my_position` adds, edits or removes one position.** No `position_id` adds
  (title, company and start date required, an end date unless `is_current`); a `position_id` edits,
  and only the fields you send change, because the platform reads the position's form first and
  submits it whole (LinkedIn replaces the form; `is_current: true` drops the end date, an end date
  ends a current position); `position_id` with `delete: true` removes it. The id is the one each
  entry now carries on the account's own experience list (`enrich_linkedin_person_experience` on
  the account's own profile), or the add's answer. The response carries the position's form as it
  was before an edit or a delete, and LinkedIn's own verdict. Spends one `edit_profile` slot per call.
</Update>

<Update label="2026-09-22" description="The Recruiter InMail balance is a third pool, and it guards Recruiter sends">
  **`recruiter_inmail_credits` on LinkedIn accounts.** LinkedIn grants Recruiter InMails per contract and
  moves nothing between its products, so a Recruiter seat holder has a third balance next to
  `inmail_credits` (the Sales Navigator seat's grant) and `premium_inmail_credits` (the Premium plan's
  own balance). The premium check's `inmail_credits` step now reads it for an account whose
  `recruiter_status` is `active`, next to whichever of the other two the plan has, and stores what
  Recruiter's Usage Overview shows as InMail credits. The field filters (`eq`, `gte`, `is_null` and the
  rest). Null means not read, never zero; a signed-out seat keeps its last number until it signs in
  again, and the number goes with the seat when the seat goes. `422 premium_required` on a named
  `inmail_credits` check now fires only when none of the three pools can be read.

  **A Recruiter send is guarded by that pool.** Opening a new Recruiter thread with
  `send_linkedin_recruiter_message` on a balance the platform knows to be zero is refused before
  anything reaches LinkedIn, with the rule `no_inmail_credits`, the way a linkedin.com InMail is refused
  on an empty plan pool. A reply into an existing thread is never guarded. A balance never read does not
  block, and LinkedIn's own out-of-credits refusal zeroes the stored balance so the next send is refused
  early.

  **Three smaller things the node release carries.** The connected account's own experience entries
  (`enrich_linkedin_person_experience` on the account's own profile) carry `position_id`, the key the
  coming profile-editing verbs take. `enrich_linkedin_person_services` answers `page_id` as the page's
  vanity slug for a published page, and null for a page its owner has unpublished, which looks the same
  as never having had one. In `get_linkedin_account_my_analytics` a daily value can be negative:
  engagements is a daily delta, and a retracted reaction shows as a below-zero day, so do not clamp it,
  the period total is the sum.
</Update>

<Update label="2026-09-20" description="The invitation note is the message LinkedIn delivers, and the accept webhooks name it">
  **`connection_note` is a label on a real message, not a copy.** When an invitation is accepted,
  LinkedIn puts the note into the 1:1 thread as an ordinary message. The platform recognizes it by its
  text and labels that row `linkedin_type: "connection_note"`; it no longer writes a second, synthetic
  copy of the note. So a note is one message, with LinkedIn's own id, and it arrives through the
  message sync like any other (`automation: "synced"`).

  **`automation` no longer takes `connect`.** That value marked the synthetic copy and nothing else, so
  no message ever carried it. The field is now `auto`, `manual` or `synced`. A filter that passed
  `connect` matched nothing before and is rejected now.

  **Both accept webhooks name the note.** `linkedin-connection-requests.accepted` and
  `linkedin-connection-invitations.accepted` carry `note_message_sid`, the `ln_ms_` sid of that thread
  message, or null when the invitation had no note or the thread has not synced yet. On invitations it
  replaces `seeded_message_sid`, which was always null. `linkedin-connection-requests.accepted` also
  carries `resolved_connection_sid`, the `ln_cn_` sid of the connection the acceptance produced, so
  reaching the new connection no longer needs a search.
</Update>

<Update label="2026-09-20" description="A custom-domain certificate stays active while it renews">
  **A renewal no longer moves the certificate out of `active`.** `renew_ssl_certificate` and the daily
  renewal job used to move the row to `challenge` for the length of the attempt, and to `failed` when the
  attempt failed, while the certificate on the domain went on serving. `status` now says what the domain
  serves: an `active` certificate stays `active` while a renewal runs and after one fails, until it expires
  or is deleted. The attempt is the `challenge` object. It is set while an attempt is in flight, on any
  status, and null otherwise.

  **After a renew, poll `challenge`, not `status`.** Read `get_ssl_certificate` until `challenge` is null.
  `last_error` null means the new certificate is live, and `issued_at` and `expires_at` have moved.
  `last_error` set means the attempt failed and the current certificate keeps serving. A client that waits
  for `status: "active"` after a renew sees it on the first read and learns nothing from it. A row that
  serves nothing (`pending`, `failed`, `expired`) moves through `challenge` to `active` or `failed` as
  before, on `issue_ssl_certificate` and on `renew_ssl_certificate`.

  **An `active` certificate with `last_error` set is still serving.** It means the last renewal failed. The
  platform retries once a day while the certificate is within 30 days of `expires_at`. The failure email is
  sent when a renewal starts failing, not on every retry, and the notice about an approaching expiry is sent
  once per certificate. Fix what `last_error` names (usually the CNAME) and call `renew_ssl_certificate`, or
  leave it to the next daily attempt. An `issue` or `renew` call while an attempt is in flight answers
  `409 issuance_already_in_progress`.

  **One live certificate per domain.** More than one workspace can register the same domain while nobody
  holds it, and the first certificate to go `active` holds the domain until it expires or is deleted. From
  then on `create_ssl_certificate`, `issue_ssl_certificate` and `renew_ssl_certificate` answer
  `409 domain_active_in_another_team` for that domain in every other workspace, and retrying does not help
  while that certificate is live. An attempt that was already in flight there ends as a failed attempt, with
  `last_error` opening with the same reason. Before, only `create` checked, so a workspace that had
  registered the domain earlier could still issue a second live certificate for it.

  **Registering a failed, expired or deleted domain again answers as a new registration.**
  `create_ssl_certificate` on a domain the workspace already has a row for keeps that row and its sid. When
  the row is `pending`, `active` or has an attempt in flight it comes back untouched, with
  `already_exists: true` and a 200. When the row is `failed` or `expired` with nothing in flight, or was
  deleted, the call resets it to `pending` and clears `challenge` and `last_error`. That answer is now
  `already_exists: false` with a 201. It used to say `true` and 200, which read as "nothing changed" on a
  row that had just lost its `last_error`. Read `last_error` with `get_ssl_certificate` before you register
  a failed domain again if you still need the reason.
</Update>

<Update label="2026-09-19" description="The burst leaves the smart-limit row: one flat delay per bucket">
  **`delay_in_seconds` is the whole pacing rule.** Since 2026-09-18 calls of one action type leave one
  at a time, and the row's "burst N, then hold X" pair only divided the number the gate used. The pair
  is gone: `batch_size` and `last_counted_at` left the smart-limit row, the `update` verb and the
  `linkedin-account-smart-limits.update` mass-action step, and `delay_in_seconds` is the flat gap
  between any two calls of the bucket (1..3600, varied by up to 20% per call; an undo still waits
  2 seconds). Existing rows were carried onto the number they were already running: a row at the
  platform pair took the platform's flat default (10 s for messages, 30 s for connection requests,
  5 s for searches and profile reads, 1 s for own-account reads), a hand-set pair kept its own
  average rate. A body that still sends `batch_size` is refused with `validation_failed`.
</Update>

<Update label="2026-09-19" description="One person is one identity, whichever LinkedIn id you hold">
  **Every LinkedIn id of a person decodes to the same member.** A profile URN (`ACoAA...`), a Sales
  Navigator id (`ACwAA...`) and a Recruiter id (`AEMAA...`) carry the same member number, and the platform
  now reads them in one place. A URN is filed by what it is, not by the field it arrived in (`ln_id:
      "ACwAA..."` lands on `sn_id`), `ln_member_id` is derived from any of them, and the same person named twice
  is one row: in the data corpus, in a mass action (two targets that decode to one member are one item,
  never two sends), in the synced connections, invitations and threads.

  **A bad identifier is refused where you sent it.** `ln_member_id` is the decoded member number, digits
  only: a vanity slug there is `422 ln_member_id_not_numeric` on that field, with the field it belongs in
  named. An id that does not decode is `urn_undecodable`; two ids that name two different people are
  `identity_conflict`; a Recruiter id given to a verb that dispatches on the member profile (connect,
  message, visit, follow, endorse, the person getters) is `profile_id_not_dispatchable` with a pointer to
  the profile URN. Mass-action previews refuse these on the target that carries them
  (`scope.targets.3.ln_member_id`), so a run no longer previews green and dies on its canary.

  **A message's `ln_member_id` can be null.** Only on a thread with an organization (a Message Ads page
  names nobody); the literal `unknown` that used to stand in for it is gone from the column.
</Update>

<Update label="2026-09-19" description="Every refusal has one shape: a field, a reason, a sentence, a context">
  **The reason is the rule, the sentence is the message.** A `validation_failed` names the field, and each entry
  under it carries `rule` (the machine reason: `ln_member_id_not_numeric`, `resend_not_available`,
  `no_connected_account`) and `message` (the sentence for it). A `conflict`, `rate_limited` or
  `service_unavailable` carries the reason in `context.reason`. Until now the shape depended on who wrote the
  refusal: some answered the bare reason as the whole message, some wrote the reason inside the message under
  the rule name `validation`, and the platform's own field checks (`The x field is required.`) named no reason
  at all. Now a refusal from the platform's field checks names the rule that failed (`required`, `max`, `in`),
  and no reason is answered without its sentence: every one is listed on the new
  [Reasons](/concepts/reasons) page, generated from the same table the backends answer from.
</Update>

<Update label="2026-09-18" description="Pacing is one call at a time, and every answer names the clock">
  **Calls of one action type leave one at a time.** A bucket no longer fires a burst and then holds: two
  calls are spaced `delay_in_seconds / batch_size` apart (10 seconds for messages, 30 for connection
  requests, 5 for searches and profile reads), varied by up to 20% per call. The slot is reserved before the
  call leaves, so two calls fired in the same second never reach LinkedIn together, which is what LinkedIn
  answered with `429 FUSE_LIMIT_EXCEEDED` on 2026-09-17. An undo (unreact, delete a comment or a post,
  unendorse, unfollow, withdraw, recall a message) waits 2 seconds, so an action and its undo fit together.

  **A wait is either slept or named.** Up to 20 seconds the server sleeps it and the call just takes
  longer. A longer wait answers `429 rate_limited` with `context.reason` `bucket_saturated`, `cause`
  `delay_not_elapsed` and `retry_after`: the same call goes through at that moment, and an earlier retry is
  refused again with the same timestamp. Parallel calls on one account queue behind each other, so run them
  one after another or spread them over accounts.

  **A success names the clock too.** Every response whose call reached LinkedIn carries
  `pacing: { linkedin_account_sid, limit_type, next_call_after, remaining_today }`. `next_call_after` is the
  next slot while the day has budget left and the daily reset when it has none. MCP tools that are paced say
  so in their description (`Paced: send_messages.`), a toolset listing states the rule once, and the OpenAPI
  carries `x-gtm-paced-bucket` on the same 110 operations.

  **One refusal no longer collapses a limit.** A LinkedIn refusal lowers a bucket's learned ceiling only to
  what the account has proven: the most it did in a day over the last 10 days, counted from sent actions and
  from the counts LinkedIn refused at. A refusal with no volume behind it changes nothing, a lowered ceiling
  expires when 10 days pass without another refusal, and rows the old rule had lowered re-derive at the
  next snapshot.
</Update>

<Update label="2026-09-18" description="A preview validates the arguments before it hands out a token">
  **The preview of a protected action asks the backend first.** A preview used to check the arguments
  against the tool schema only, so a call the platform would refuse with `422` was previewed, confirmed, and
  refused at commit. The preview now runs the real route's own checks (authentication, permissions, request
  validation) without running the action. A refusal comes back at the preview, as the same error the commit
  would give, and no `commit_token` is issued for it. A preview that passed says `validated: true`. That is
  a statement about the arguments: the daily budget, a Sales Navigator seat or whether the person is
  connected are only known when the action runs. LinkedIn tools today; identity and mass-action tools
  preview as before.

  **`select_linkedin_account_recruiter_contract` is a protected action.** Switching the contract changes
  which seat and inbox the session acts under, so it previews like the other outward actions.
</Update>

<Update label="2026-09-18" description="API request log: why a call was refused, on which account, uploads included">
  **A refused call says why.** `search_api_requests` rows carry `error_code`, `error_reason` and
  `error_cause`, read off the envelope the client received, and `account_sid`, the account the call named.
  `rate_limited` is four different refusals and the three together tell them apart: a saturated bucket, a
  hold a person set, LinkedIn's own lock, the per-minute guard. All four filter with
  `eq / ne / in / nin / is_null`, so "everything refused as `held` on this sender" is one call. The
  dashboard has chips for All / Errors / 4xx / 5xx, prints the reason in words in the row and opens the
  whole row on click.

  **A split by client is named.** `get_api_request_metrics` with `group_by: "client_sid"` returns `label`
  (the API key's name, revoked keys included, or the OAuth client's) and `credential_kind` on every group.

  **An upload through a one-time link is in the log.** The `PUT` to an upload link is recorded as a call of
  the client that requested the slot (`PUT external/uploads/{token}`), refusals included, so a `422` on the
  file has a row to point at.
</Update>

<Update label="2026-09-18" description="Sales Navigator calls re-arm a stale session by themselves">
  **`403 SALES_SEAT_REQUIRED` is a stale session, and the platform now handles it for every call.**
  LinkedIn's Sales Navigator API refuses a seated browser that has not opened a Sales Navigator page for a
  while. The background sync already re-opened it; direct calls (`get_linkedin_account_my_credits`, the
  Sales Navigator searches and typeahead, the Sales Navigator inbox reads and sends) failed on the first
  refusal. They now re-arm the session and retry once. A seat that turns out to be gone answers
  `422 sales_nav_required`; a second refusal right after the re-arm answers `409 sales_session_stale`.

  **The Sales Navigator inbox sync no longer depends on the account's own `sn_id`.** Who in a thread is the
  account owner is decided by the member id the participant decodes to. Accounts that had no `sn_id` stored
  skipped every page of that sync; they sync now.

  **Searching by a person's identifier finds the person.** A filter on `ln_id`, `sn_id` or `recruiter_id`
  matches the member on every surface for every operator (`ne` and `nin` included, which some searches
  used to ignore), on conversations, messages, connections, invitations, requests, followers and the
  activity and block logs. `search_linkedin_messages` also filters by `client_reference`.
</Update>

<Update label="2026-09-17" description="Upload slots for post media, media by URL on create-post, image checks before dispatch">
  **A place to put a file the caller cannot send inline.** `request_media_upload` (the id platform mount)
  reserves an upload slot and answers with a one-time upload link on our domain (a person opens it and drops
  the file, a shell runs `curl -T <file> <link>`), a public `file_url` to pass on, and, when `file_type` is
  given, a pre-signed S3 form. A slot takes one file of up to 35 MB within 30 minutes, the object is kept for
  7 days, and a workspace may request 120 slots an hour. Purpose `post_media` (PNG, JPEG, GIF, WEBP, MP4,
  MOV, WEBM) is the only purpose today.

  **`create_linkedin_post` takes media by URL.** Each `images[]` member and `video` carries exactly one of
  `file_base64` or `url`; a URL, an upload slot's `file_url` included, is downloaded by the platform over
  https from a public host and checked before the post is dispatched. Typing a file out as base64 damages
  it: an agent that re-typed a PNG produced a corrupt file, and LinkedIn answered with an empty 400 after the
  upload. A damaged image is now refused up front, before any browser or LinkedIn call: `422` on
  `images[i].file_base64` (or `images[i].url`) with rule `image_not_base64`, `image_format_unrecognized` or
  `image_corrupt`.

  **Every URL the platform downloads for you is https and public.** `attachments[].file_url` on
  `send_linkedin_message`, `send_linkedin_inmail`, `send_linkedin_sales_nav_message` and
  `send_linkedin_recruiter_message`, and `audio.url` on `send_linkedin_voice_message`, must be https URLs of
  public hosts. A private or reserved address, credentials in the URL or a port other than 443 answer `422`
  (`attachment_url_invalid` / `attachment_url_host_forbidden`, `audio_url_invalid` /
  `audio_url_host_forbidden`); the codes for a failed or oversized download are unchanged.
</Update>

<Update label="2026-09-17" description="Reactions and comments land on the post's social thread">
  **The tools address a post's social thread for you.** LinkedIn files reactions and comments under the
  post's own `urn:li:ugcPost:` or `urn:li:groupPost:` for those families, and under the activity urn for a
  `urn:li:share:` post. A reaction sent to the wrong key answered success and landed nowhere, and a comment
  was refused with `linkedin_400`. `react_linkedin_post`, `unreact_linkedin_post`,
  `create_linkedin_comment`, `scrape_linkedin_get_post_reactors` and `scrape_linkedin_get_post_comments` now
  take a post urn of any family or a post link (a `-ugcPost-<id>-` share link and a
  `/feed/update/urn:li:ugcPost:<id>/` permalink included) and name the thread before the wire. An activity
  or share urn costs one post read on the acting account, cached for 7 days; a post that cannot be read
  answers `422 post_not_resolvable`. `post.post_ln_id` in the reactor and comment lists is the urn the page
  was read by.
</Update>

<Update label="2026-09-17" description="Mass actions: the sender is a step arg, a target's own extras win, preview says more">
  **A send step names its sender in `args`.** `preview_mass_action` refuses a
  `linkedin-connection-requests.send`, `linkedin-messages.send` or `email-messages.send` step without
  `args.linkedin_account_sid` / `args.email_account_sid` (`422`, rule `sender_required` on
  `plan.steps.{i}.args.<key>`). Such a run used to pass preview and fail on its first item.

  **A target's own `client_reference`, `note` and `allow_no_note_fallback` win over the step args.** The
  args stay the run-level default. This reverses the 2026-09-16 note below: `client_reference` is the key of
  one row, and a run that names one per target keeps them.

  **`preview_mass_action` warns and estimates.** `warnings[]` lists every `{{placeholder}}` in the step args
  (the platform renders no merge fields; the braces go out as typed), and `eta.estimated_completion_at` is
  filled for a scheduled run: the first item starts at once, each further gap is the mean of the schedule's
  interval.

  **`append_mass_action_items` takes payload leads only.** A generate run (step 1 is a `.create` verb) and
  an objects run (items addressed by an existing row) answer `422 run_not_payload_addressed`; create a new
  run instead.

  **Runs authored with an API key work.** The identity behind a `gtm_live_` key now carries the workspace's
  home cluster, so a mass action created through the REST API no longer fails its first channel step.

  **`get_webhook_logs_metrics` rates are ratios of terminal outcomes.** `success_rate` and `failure_rate`
  divide by succeeded plus failed deliveries and are `null` while nothing terminal exists in the period;
  deliveries still retrying no longer read as a zero failure rate.
</Update>

<Update label="2026-09-17" description="Stricter tool arguments, live reads marked, smart limits and InMail">
  **Unknown keys in tool arguments are refused.** The MCP tools and the REST edge parse strictly: a key the
  schema does not know answers `422 validation_failed` with rule `unknown_key` and a hint, for example the
  nested `filter` shape or `filter.q` for free text. A dotted filter key that used to fall through as an
  empty filter now fails loudly.

  **The ten `get_my_latest_*` tools are live reads.** They start the account's browser and read LinkedIn, so
  they carry `readOnlyHint: false` and `openWorldHint: true`; an MCP client that auto-approves read-only
  tools will ask first.

  **`set_linkedin_account_smart_limits` remembers nothing.** A real flip in either direction puts every
  limit row of the account back on the platform defaults first (cap, hold, burst, target), so off never
  inherits the ramp's last cap and on never inherits a hand-typed one.

  **Smart-limit rows of a deleted account are gone from the public surface.**
  `search_linkedin_account_smart_limits` no longer lists them, and `update_linkedin_account_smart_limit`
  and `reset_linkedin_account_smart_limit_hold` answer `404`.

  **InMail credits guard the send.** `send_linkedin_inmail`, and a `send_linkedin_sales_nav_message` that
  opens a new thread outside the network, refuse before the wire when the account's pool is known to be
  empty (`422 no_inmail_credits`). The balance LinkedIn reports after a send is written back to the account,
  a `NOT_ENOUGH_INMAIL_CREDIT` refusal zeroes it, and a stale Sales Navigator session is re-armed once before
  `409 sales_session_stale`.

  **Cloud-browser access keys are shown only to callers who may mint them.** A read with a token that lacks
  `can_manage_cloud_browser_external_links` gets the `cloud_browser_access` entries with `key: null`; the
  link, its expiry and its use stay visible. `generate_cloud_browser_access_key` takes `send_to_email`: the
  platform mails the link to that address, and the result carries `sent_to_email`.

  **Group conversation verbs are tracked.** `add_linkedin_conversation_participants`,
  `remove_linkedin_conversation_participants` and `rename_linkedin_conversation` write an activity-log row
  (`activity_log_sid` in the result), a LinkedIn refusal is the typed `409 linkedin_refused`, and
  `participant_ln_ids` take bare profile ids (a urn is `422`).

  **Voice messages: our failure is not your file.** A transcoder missing on the platform answers
  `503 audio_transcoder_unavailable`; audio that cannot be decoded answers `422 audio_invalid`.

  **Proxy location checks no longer report a false mismatch.** A check that could not conclude
  (`no_ip_port`, `exit_unreachable`, `no_corroboration`) is recorded as inconclusive.
</Update>

<Update label="2026-09-17" description="Workspace deletion in 30 days with two notices, deleted workspaces cleaned up everywhere, smaller fixes">
  **The data-retention clock is 30 days, with two emails and no restore.** After a terminal loss of coverage
  a workspace is scheduled for deletion 30 days out (it was 8 weeks): the owner gets one email when the date
  is set and a final notice 72 hours before, and the deletion runs only after the final notice went out.
  `delete_team` answers `recovery_available_until: null`: a deleted workspace is not restored.

  **A deleted workspace is cleaned up on every service.** On deletion its webhooks stop and its mass-action
  runs are stopped; its LinkedIn accounts are disconnected and the platform's own browser profiles released.
  Thirty days later every row the workspace owned is deleted for good, LinkedIn data, browsers, logs and
  photos included.

  **`linkedin-connection-requests.resend-available` is sent.** The event was in the webhook catalog but
  nothing emitted it; an hourly sweep now announces each withdrawn request once its 21-day cooldown has
  passed, once per row.

  **An auto scrape run waits out a pacing refusal instead of failing.** A page refused by the scraping
  bucket's batch pace (`delay_not_elapsed`) parks the run at its cursor and resumes it; only budget
  exhaustion (`daily_saturation`, a hold, a LinkedIn quota) fails a run. Runs used to fail on page 3 and
  restart from page 1.

  **A browser parked by a platform maintenance window comes back on its own.** It is released to `idle`
  and wakes on its next due sync or dispatch; it used to be released to `stopped`, which nothing restarted.

  **The card a first checkout paid with shows up.** The first transaction of a subscription is attached to
  it even though Paddle delivers it before the subscription event, so the payment-methods list derives the
  billing card from it right after checkout.

  **Data request reads no longer hit MySQL's sort-memory limit.** A workspace with one large cached result
  (over 256 KB) got `500` on cache reuse, on a nickname resolve and on `search_data_requests`; those reads
  were reworked and the lookups are index-served.
</Update>

<Update label="2026-09-16" description="Removing a card a subscription still uses answers 409">
  **A card a subscription still uses is a conflict, not a server error.**
  `delete_billing_payment_method` (`DELETE /api/billing-payment-methods/{sid}`) only takes a method
  off the paying user's saved list; it never changes the card a subscription is billed to. Paddle
  refuses to delete a saved method that an active, paused or past due subscription uses, and that
  refusal used to come back as `500 internal_error`. It is now `409 conflict` with
  `context.reason: payment_method_in_use`, the `sid`, a message and a suggestion: move the
  subscription to another card with the link from `get_billing_payment_method_add_link` first, then
  remove the old card. A card that is not `is_default` can get the same answer when it pays for
  another subscription of the same payer, in another workspace or one not applied to any.

  **The card the plan is billed to is refused up front.** The method listed with
  `is_default: true` is usually not a saved method at all, so deleting it used to answer
  `already_deleted: true` while the card kept billing. While the plan is active, past due or paused
  it now gets the same `409 payment_method_in_use`, with a suggestion to switch the plan to another
  card first. Once the plan is canceled, its last card is removed like any other.

  **`get_billing_payment_method_add_link` is no longer annotated read-only.** Every call opens a
  new portal session, so MCP clients that approve read-only tools automatically will ask before
  calling it.
</Update>

<Update label="2026-09-16" description="client_reference on every send verb, and on mass-action sends">
  **Every send verb takes `client_reference`.** The field (your own key for a send: a task id, a
  campaign, an idempotency token; max 255) used to be accepted by the basic message, the InMail and
  the connection request only. It is now accepted by `send_linkedin_sales_nav_message`,
  `send_linkedin_recruiter_message`, `send_linkedin_voice_message` and
  `start_linkedin_group_conversation` too, with the same contract: stored as given on the message
  row, searchable through `filter.client_reference` on `search_linkedin_messages`, and echoed on
  `linkedin-messages.sent`. A Sales Navigator or Recruiter send that lost its response can now be
  checked before it is repeated, exactly like a basic one.

  **Mass-action sends carry the plan's key.** A `linkedin-connection-requests.send` or
  `linkedin-messages.send` plan step with `client_reference` in its args tags every row the run
  creates and every event about those rows, the way `email-messages.send` already did. One value per
  run: it is the run's tag, not a per-item token.
</Update>

<Update label="2026-09-15" description="A Recruiter status on the account, a sign-out notification, and the contract chooser as tools">
  **`recruiter_status` replaces `has_recruiter` on LinkedIn accounts.** The flag said that a seat
  existed and nothing about whether its Recruiter session still answered, so a seat LinkedIn had
  signed out looked exactly like a working one until a call ran into the 401. The new field carries
  both facts: `null` = no Recruiter seat, `active` = the seat answers, `signed_out` = LinkedIn
  Recruiter asks the seat holder for the password again (it does so about every 30 days). It
  filters (`eq`, `in`, `is_null`), and `linkedin-accounts.premium-changed` carries it in place of the
  flag. Existing seat holders start as `active`; the platform sorts the signed-out ones within
  minutes. This is a breaking rename: read `recruiter_status` where you read `has_recruiter`
  (`recruiter_status !== null` is the old `true`).

  **You hear about a sign-out once.** The status flips to `signed_out` on the first refused
  Recruiter call, and a 15-minute check of the session clock catches it before a sync does. The
  crossing sends one webhook, `linkedin-accounts.recruiter-signed-out` (with `reason`:
  `session_expired` or `wire_401`), and one email to the person who connected the account; the way
  back sends `linkedin-accounts.recruiter-signed-in`. While signed out, Recruiter calls answer
  `409 recruiter_reauth_required` with `context.account_url` (the account's page) and a suggestion
  an agent can relay; the recruiter inbox sync waits.

  **Sign in to Recruiter from the account's page.** The button opens the account's cloud browser
  straight on Recruiter's sign-in; after you confirm, the platform restarts the browser, re-checks
  the seat and flips the status back to `active`. Only the seat holder can do it, and the platform
  never stores the password.

  **The contract chooser as tools.** `get_linkedin_account_my_recruiter_contracts` lists the
  Recruiter contracts the account's member can act under, with `current_contract_id`;
  `select_linkedin_account_recruiter_contract` binds the browser to one of them (`contract_id` exactly
  as listed; `422 contract_not_offered` names the rows on offer). Both live on the recruiter mount and
  neither is gated on the session clock. The premium check still binds an unambiguous choice by
  itself; these are for the case where it could not.
</Update>

<Update label="2026-09-15" description="Acceptance detection, sending by profile URL, widening a webhook">
  **A connection request is accepted the moment we can prove it.** `linkedin-connection-requests.accepted`
  now fires from three places: the connection-request sync, which refreshes the head of the
  connections list before it judges an invitation that left the sent list; the connections sync
  itself, which resolves a pending request the moment the connection row appears (`.accepted` lands
  before that row's `.added`, and the row carries `linkedin_connection_request_sid`); and the accept
  of an invitation whose inviter is an account on this platform. Until now the sync judged by the
  connection rows it already had, so an acceptance the connections sync had not read yet was recorded
  as `withdrawn` with a 21-day resend cooldown, and a request past the first page of the sent list was
  recorded as withdrawn every day (LinkedIn's sent-invitations view reports a zero total). Both are
  fixed, the rows this produced were repaired, and a request whose invitation is seen again on the sent
  list goes back to pending on its own.

  **Send by profile URL.** `send_linkedin_connection_request` and `send_linkedin_message` take
  `public_identifier` (a vanity slug or a `linkedin.com/in/<slug>` URL) next to the URN. The slug is
  resolved server-side: your own rows first, then the corpus, then a lite-profile read on the sender,
  which spends the `enrichment` bucket like any enrichment. One addressing form per call.

  **A narrowed webhook can be widened.** `update_webhook` with `filters: null` or `filters: {}` clears
  the account narrowing. Before, neither spelling worked and the only way was to delete the webhook and
  create it again.
</Update>

<Update label="2026-09-15" description="The Premium InMail balance, the Recruiter contract chooser and a wider own-account burst">
  **The Premium plan's InMail balance is read.** Accounts carry two InMail fields now, because
  LinkedIn keeps one pool per product and moves nothing between them: `inmail_credits` is the Sales
  Navigator seat's grant, read only while the account holds a seat, and `premium_inmail_credits` is
  the Premium Career or Business balance, read for a Premium account without a seat. The premium
  check's `inmail_credits` step reads whichever pool the plan has; naming it for an account with no
  Premium answers `422 premium_required` (it used to answer `422 sales_nav_required` for a seatless
  Premium account, whose balance was never read at all). Both fields filter. Null means not read,
  never zero.

  **Several Recruiter contracts.** A seat holder on several contracts holds no seat until the browser
  is bound to one. The premium check now binds it when the choice is unambiguous (the contract the
  account is already on, the only contract, or the only corporate one) and stamps the seat from the
  answer; when several corporate contracts are on offer the seat holder picks in the account's
  browser and the next check stamps it. A missing Recruiter session cookie no longer reads as an
  expired session: `recruiter_session_expires_at` is null until the cookie is seen, and the Recruiter
  wire's own refusal is what answers `409 recruiter_reauth_required`.

  **Own-account reads burst 8.** The self-account bucket paces 8 calls per 6-second hold (was 5),
  so one premium check fits a single burst; the daily cap is unchanged.
</Update>

<Update label="2026-09-05" description="The LinkedIn Recruiter inbox is the third messenger, with its seat, its session clock and the 24-hour rule">
  **273 operations**: LinkedIn 179, ID and Teams 72, Orchestration 22.

  **Recruiter conversations are their own surface.** Threads and messages carry
  `messenger_type: "recruiter"` and the candidate's `talent_id`; `sync-my-recruiter-conversations`
  runs the background sync (one run walks the INBOX tab and then UNRESOLVED, where a thread you
  opened sits until the candidate replies), `get-my-latest-recruiter` on conversations and on
  messages is the head refresh, and `recruiter_conversations` joins the sync, webhook and
  reset-sync vocabularies with the same 120-minute default cadence as Sales Navigator. The account
  carries `recruiter_seat_id`, `recruiter_contract_id`, `recruiter_session_expires_at` and
  `last_recruiter_conversations_sync_at`, all filterable; the two clocks sort.

  **The seat and the session are facts on the account, not inputs.** The premium check
  (`checks: ["recruiter"]`) stamps the seat number and its contract and reads the expiry of
  LinkedIn's 30-day Recruiter session. Recruiter calls without a seat answer
  `422 recruiter_required` or `422 recruiter_seat_unresolvable`; past the session clock they answer
  `409 recruiter_reauth_required` until the seat holder signs in to Recruiter in the account's
  browser. No Recruiter password is ever stored. `get-my-recruiter-seat` and
  `get-my-hiring-projects` read the seat's own entitlements and hiring projects.

  **The Recruiter people search and its typeahead.** `search-recruiter-people` runs the LinkedIn
  Recruiter search from a `filters` object (chips with `exclude`, `required` and a per-facet `scope`,
  the closed code sets, the three year sliders) or from a pasted Recruiter `url`, 25 rows a page, 40
  pages at most, LinkedIn's real `total`, and rows that carry the headline, current position, location,
  connection degree, `talent_id`, `can_send_inmail` and `open_to_work`. A Recruiter URL carries no
  filters, only the `searchHistoryId` of the search LinkedIn keeps on the seat, so the url half replays
  that stored search; every answer returns `search_history_id` and `search_url`, the handle to page by
  without re-sending the filters. `recruiter-param-id-lookup` resolves the eleven
  chip kinds into their ids. LinkedIn's per-seat search throttle answers `429
      recruiter_search_usage_limit` with LinkedIn's own sentence and a one-minute `retry_after`. On MCP
  both ride on `/mcp/linkedin/scraping` with every other live list.

  **Sending is `send-recruiter`.** One verb over two LinkedIn wires: a new thread by `talent_id`,
  `ln_id` or `sn_id`, or a reply by thread sid. LinkedIn's one InMail per candidate per 24 hours
  is enforced before dispatch and read back from the wire: `429 rate_limited` with
  `reason: "recruiter_inmail_cooldown"` and a `retry_after`, on a fresh InMail and on a reply the
  candidate has not answered yet.

  **The first sync now tells you when it is done.** The person who connected a LinkedIn account gets
  `Your LinkedIn account is ready to use` when its onboarding sync latches, with the connections and
  conversations it brought in and how long it took. A large account can spend its first-day read
  budget part-way: it then carries `initial_sync_held_at` and `initial_sync_hold_reason` (filterable),
  emits `linkedin-accounts.initial-sync-held`, resumes on its own after the daily reset, and the mail
  arrives a day later saying so. Sent once per account; a reset-sync re-latch sends nothing.

  **In the app**, the **Recruiter Conversations** row appears on the Account Sync tab once a seat is
  detected, with its own interval, Sync now and Reset sync; the initial-sync gate lists the
  surface; the Premium block shows the seat and the session's expiry, and says so once it has
  expired.
</Update>

<Update label="2026-08-30" description="Browser ownership is derived, deletes confirm the stop, and a key is not a person">
  **262 operations**: LinkedIn 168, ID and Teams 72, Orchestration 22.

  **`browser_owner` is no longer an input.** `POST /api/antidetect-browsers` derives it from the one
  fact that decides it: send `vendor_profile_id` and the profile is yours (`customer`), omit it and we
  mint one in our own vendor account (`platform`). The field stays on the row and in filters, but its
  vocabulary is now exactly those two values; `mirror_profiles` is gone from the enum. Sending
  `browser_owner` on create is a validation error naming the field.

  **`vendor_profile_id` is validated as what it is**: a GoLogin profile id, 24 hex characters, not a
  profile URL or a share link. A profile that is not shared with our vendor account answers
  `422 vendor_profile_not_found`; an owning account over its GoLogin plan answers
  `402 vendor_profile_plan_limit`, and retrying will not help until the plan changes.

  **Deleting a running browser now stops it first and waits for the confirm.** The call can take up to
  \~20 s; if the node does not confirm the teardown, the delete is refused
  `409` with `context.reason: browser_stop_unconfirmed` and nothing is deleted - retry in a minute
  rather than assuming success.

  **A key is not a person.** `GET /api/users/current` (`get_current_user`) on an API key answers `403`
  with `context.reason: not_a_user_actor` instead of a misleading validation error about a `sid` field
  you never sent. No permission on the key changes that: the endpoint returns a signed-in person's
  profile, and a key has none. The same applies to `PATCH /api/users/current`.

  **A freshly connected sender starts on business hours.** Its `sync_config.window` is seeded
  Monday-Friday 09:00-18:00 in the workspace timezone (UTC when the workspace has none). An empty
  window still means round the clock, as it always has - there is no "off" through this field.
</Update>

<Update label="2026-08-28" description="Person languages goes GA, short links resolve, and hand-off links carry a purpose">
  **Person languages is live.** `POST /api/linkedin-enrichment/person-languages` answers with the
  profile's Languages card (raw `name` plus optional `proficiency` strings) instead of `501`. Same
  shape and paging as certifications and recommendations.

  **`lnkd.in` short links are a first-class input.** LinkedIn's own "Copy link to post" mints
  `https://lnkd.in/p/...`, so every endpoint that takes a post link now accepts one and expands it
  server-side: enrichment reads, `get-activity-urn-by-url`, reactions, reshares. A post page URL also
  resolves its backend `share` / `ugcPost` urn where only the activity urn resolved before.

  **Cloud-browser links say what they are for.** `generate-cloud-browser-access-key` takes `purpose`:
  `relogin` (the default, and what every key minted before the field carries) walks the visitor
  through a LinkedIn sign-in whose confirm re-binds the account; `share` just hands the browser over,
  with a Done button instead of a sign-in flow. The mass-action step forwards the same argument, so a
  batch of links minted to be handed out must say `share` explicitly.

  **`used_connects` is gone from access-key entries.** Nothing ever incremented it, so every reader
  saw `0` forever. `max_connects` caps CONCURRENT sessions and is enforced against live
  `cloud-browser-sessions` rows at connect time. On session rows, `access_key` is now masked to
  `cb_ak_********` plus the last 4 characters - enough to attribute a session to a key you hold, and
  nothing anyone else could redeem.

  **Workspace capacity in one read.** `POST /api/antidetect-browsers/seat-usage` answers the seats and
  5G proxy slots the whole workspace occupies against its plan ceilings - what the plan card prints,
  independent of any member's account scope.

  **Held profiles get a schedule.** A workspace holding more browser profiles than its plan allows
  gets a warning at day 21 of the hold and a teardown of the over-cap profiles at day 28, oldest
  first. Growing the plan back at any point before day 28 keeps everything.

  **Message attachments deliver again.** `send-message` with `attachments` produced
  `handler_failed: Invalid File URL` on the wire since the wire expects a full `data:` URL; fixed
  server-side, no request change needed.
</Update>

<Update label="2026-08-27" description="One operator field on a sender, and it is now searchable">
  **`display_name` is gone from the LinkedIn account.** It is off the account object in every response,
  off `PATCH /api/linkedin-accounts/{sid}`, and its column is dropped. The field was meant to override
  the LinkedIn name wherever an account was listed, and it never did: a few screens honoured it and the
  rest read the synced name, so one account could answer to two names depending on which page asked.
  The name a sender carries is LinkedIn's own `full_name`, refreshed by the account snapshot, and no
  endpoint edits it.

  **The update verb now sets `label`, and only `label`.** In MCP the tool `update_linkedin_account` is
  renamed `set_linkedin_account_label`. `label` is a required key rather than an optional one: send the
  field on every call, with a string to set it or `null` to clear it. An empty body is now a validation
  error that names the field, instead of a `nothing_to_update` whose cause you had to guess.

  **`label` is searchable and filterable.** `filter.label` takes `eq`, `ne`, `in`, `nin` and `is_null`
  (`is_null: true` finds untagged senders), and the reserved `q` now runs its LIKE over `label`
  alongside `full_name` and `nickname`, so "find the account of Jane Roe" and "find the one we call
  burner #3" are the same search. It is deliberately not a sort axis: the tag is sparse, so a page
  ordered by it is a page of nulls.

  **The label is private to the workspace that wrote it.** LinkedIn never sees it and no recipient ever
  sees it, and sharing or transferring an account hands over the account, not your note about it, so
  the borrowing team's copy starts empty.
</Update>

<Update label="2026-08-22" description="Scheduled posts, media posts, mentions, and explicit browser states">
  **263 operations**: LinkedIn 167, ID and Teams 74, Orchestration 22.

  **Scheduled posts.** `POST /api/linkedin-posting/create-post` takes `scheduled_at` (ISO 8601, in the
  future) and LinkedIn queues the post instead of publishing it. A scheduled share answers with
  `post_urn` only - `activity_urn` and `url` stay null until it publishes - and that `post_urn` is the
  handle the new pair takes: `POST /api/linkedin-posting/get-scheduled-posts` lists the account's queue
  (or a company page's, with `author_organization_id`), and
  `POST /api/linkedin-posting/delete-scheduled-post` removes a draft by that backend urn. A refused
  delete is `409 scheduled_post_not_deleted`, never a success body. All of it spends the `posting`
  bucket, which grew to fit: 20 a day in series of 3 before the 1200 s pause (4 a day on the free plan).

  **Media posts.** `images` takes up to 20 (array order is carousel order; with two or more the service
  supplies the author id itself, there is no `profile_id` to send), or one `video` instead. LinkedIn
  does not mix the two in a share, so sending both is a 422, and the decoded bytes across all media
  must stay under 35 MB. These calls are synchronous: a heavy upload can outlive your client timeout
  while the post still publishes, so do not retry a timeout blindly - read the queue or your own posts
  first.

  **Posting as a page, into a group, as a partnership.** `author_organization_id` (the bare numeric
  company id) publishes as a page the account administers; `group_id` posts into a group and is
  mutually exclusive with `visibility`; `brand_partnership: true` adds LinkedIn's label.

  **Mentions.** `create-comment` takes `mentions` as `{profile_id, name}` pairs, `name` being an exact
  substring of `text`, matched left to right in list order. `create-post` takes ready positions
  instead, `{profile_id, start, length}` in UTF-16 code units - the way JavaScript counts string
  length, so an emoji is two. Both are validated before anything is dispatched.

  **Reactions on comments, and a new reaction.** `entity_urn` on `react` and `unreact` now takes a post
  urn in any family (`activity`, `share`, `ugcPost`, `groupPost`) or a comment urn in either form, so a
  reaction can land on a comment, and `react` returns `reaction_urn`. `reaction_type` gains
  `interested`, LinkedIn's reaction on event posts; on an ordinary post LinkedIn silently ignores it and
  `reaction_urn` comes back null, which is the tell. `POST /api/linkedin-scraping/get-post-reactors`
  accepts a comment urn in `post` to read a comment's reactors.

  **Reactor rows changed vocabulary.** `reaction_type` on `get-post-reactors` rows now uses the values
  you write with (`like`, `celebrate`, `support`, `love`, `insightful`, `funny`, `interested`). Until now
  the rows leaked LinkedIn's internal names (`interest`, `praise`, `empathy`, `appreciation`,
  `entertainment`) while the schema promised ours; a caller matching on the old strings must switch.

  **Resharers.** `post` on `get-post-resharers` takes a post URL, an activity urn, or a backend
  `share` / `ugcPost` urn. A `groupPost` urn is refused `422 reshare_target_unsupported`: group posts
  have no reshare feed.

  **Undo verbs, and recalling a message** (shipped 2026-08-20). `delete-post`, `delete-comment` and
  `unreact` retract what `create-post`, `comment` and `react` did, spending the same bucket as the
  action they undo. `POST /api/linkedin-messages/{sid}/delete-on-linkedin` recalls one of your own
  messages for every participant. LinkedIn allows that only within an hour of sending, so an older
  message answers `409 message_too_old_to_recall` with nothing dispatched; the row stays in the thread
  marked `is_deleted` with an empty body.

  **`is_deleted` on message rows.** Every message row carries it, and the sync keeps it true to
  LinkedIn: a message recalled by either side is marked on the next read of its thread, with the body
  emptied. Sales Navigator threads carry no recall marker on LinkedIn's side and are left alone.

  **Post readers.** `person-posts`, `company-posts`, `post-details` and the post-by-url read carry
  `group`, the group a post was published in (null elsewhere). On a group post LinkedIn shows the
  group as the card actor, so `author` there is a bare name with no profile url or ids; do not read
  that as an organization author.

  **Auto-scrapes.** A `get-post-comments` source accepts `sort_order` in `source_input` (`RELEVANCE`,
  `CHRONOLOGICAL`, `REVERSE_CHRONOLOGICAL`). It is frozen at create and used on every page of every
  run, because LinkedIn's pagination cursor belongs to the order it was issued under.

  **Browser states are explicit.** A call that needs a browser which is down no longer times out or
  hides behind a generic conflict. `503 browser_unavailable` says the session is gone;
  `503 browser_unreachable` says the service tried once to start the browser inside the call and
  carries the error that attempt hit plus a `retry_after`; `503 browser_starting` says a cold start is
  still warming up; `409 browser_logged_out` says the account itself is signed out;
  `409 no_live_browsers` says an enrichment or scrape with no pinned account found no live browser to
  run on; `503 infrastructure_maintenance` says the browser fleet is being updated.

  **Sender health.** `POST /api/linkedin-accounts/search` counters carry `signed_out_count`, so a
  logged-out sender shows without opening each row.

  **Billing.** `POST /api/billing-subscriptions/{sid}/undo-cancel` reverses a scheduled cancellation
  before the period ends.

  **Response meta.** Every response `meta` block now carries `team_sid` and `actor_type`, across all
  three services.
</Update>

<Update label="2026-08-16" description="5G Proxy reaches the antidetect-browser surface">
  **256 operations**: LinkedIn 159, ID and Teams 73, Orchestration 22, Support 2.

  **`proxy_5g` on antidetect browsers.** The 5G Proxy add-on is now on every surface of the
  resource: the browser row carries `proxy_5g`, both `POST /api/antidetect-browsers` and
  `POST /api/antidetect-browsers/update-proxy` accept it, and `POST /api/antidetect-browsers/search`
  filters on it (`proxy_5g: {eq: true}` is how slot usage is counted). Arming a browser with no
  add-on headroom left is refused `402 insufficient_proxy_5g_slots`.
</Update>

<Update label="2026-08-14" description="Scraping and enrichment pick an executor for you">
  **Automatic executor selection.** Every scraping and enrichment method runs on your own connected
  accounts. Pin `linkedin_account_sid` and the call runs on that account only, refusing
  `429 bucket_saturated` when its daily budget is spent. Leave it out and the service picks a
  connected account with remaining budget for you, skipping accounts on hold or out of capacity;
  Sales Navigator methods narrow the pick to accounts holding a live seat. A call with no ready
  account answers `422` with a message starting `no_connected_accounts:` (or `sales_nav_required:`),
  and a team whose accounts are all at capacity gets `429` with `retry_after`.
</Update>

<Update label="2026-08-12" description="The three own-dashboard reads">
  **259 operations**: LinkedIn 159, ID and Teams 77, Orchestration 21, Support 2.

  **Three feeds off your own LinkedIn dashboard.** All three are one-shot, cursor-paginated reads on
  an account you connected, and none of them writes anything.

  * `POST /api/linkedin-accounts/{sid}/get-my-profile-views` returns who viewed the profile over the
    last 90 days, newest first. The window and the sort are LinkedIn's and there is no filter. Rows
    carry rendered text ("Viewed 1w ago"), never a parseable date, and an anonymized viewer arrives
    with `is_anonymous` true, no member ids and a people-search url where the profile would be, so
    branch on that flag before you key a row.
  * `POST /api/linkedin-accounts/{sid}/get-my-catch-up` returns the nurture cards on My Network
    (birthdays, job changes and work anniversaries among the account's connections), each with the
    one-click message LinkedIn printed on the button. It lists prompts and sends nothing. There is
    no `page_size` here: LinkedIn's own request carries no count knob and the page is server-fixed
    at 10. Key a card by `card_urn`, which identifies the prompt rather than the person.
  * `POST /api/linkedin-accounts/{sid}/get-my-sales-nav-notifications` returns the Sales Navigator
    alert bell, and needs a Sales Navigator seat on the account. The feed is ordered by LinkedIn's
    relevance score rather than reverse-chronologically, so sort by `published_at` yourself. Alerts
    arriving without an id are dropped, which means a page can be shorter than `page_size` while the
    feed continues: do not read a short page as the end here.
</Update>

<Update label="2026-08-09" description="LinkedIn product and school search">
  **260 operations**: LinkedIn 160, ID and Teams 77, Orchestration 21, Support 2.

  **LinkedIn products search.** `POST /api/linkedin-scraping/search-products` searches LinkedIn's
  product catalogue and returns product rows (slug, url, name, category line, vendor name, tagline,
  top features, a connections counter and artwork). Send `filters` with a required `keywords` plus
  any of `free_version`, `product_category` and `product_company`, or send a
  `https://www.linkedin.com/search/results/products/` url the search screen produced. Exactly one of
  the two.

  The two id filters take digit strings from two different places: `product_category` ids come from
  `POST /api/linkedin-scraping/param-id-lookup` with `type=product_category`, while
  `product_company` takes ordinary LinkedIn organization ids. Nothing checks one against the other,
  so a category id used as a company id returns an empty page rather than an error. The identity
  field on a row is `product_slug`, a slug rather than an id, and it cannot be fed back into either
  filter.

  **LinkedIn schools search.** `POST /api/linkedin-scraping/search-schools` searches LinkedIn school
  pages and returns school rows (slug, url, name, location line, a students-and-alumni counter and a
  blurb). `keywords` is the whole filter vocabulary here, so every other facet the schools screen
  offers is reachable only by pasting a `https://www.linkedin.com/search/results/schools/` url into
  the same endpoint. There is no school-id filter: the ids `param-id-lookup` returns for
  `type=school` do not fit this endpoint, and sending one is refused rather than quietly ignored.

  The identity field is `school_slug`, a slug rather than an id, and it can arrive URL encoded, so
  pass it through as you got it. `students_alumni_count` is a rounded figure parsed from the text
  LinkedIn printed, counting students and alumni, not employees.

  With these two the LinkedIn search surface is complete: people, companies, both Sales Navigator
  searches, service providers, posts, jobs, events, groups, courses, products and schools.
</Update>

<Update label="2026-08-08" description="LinkedIn course and group search">
  **259 operations**: LinkedIn 159, ID and Teams 77, Orchestration 21, Support 2.

  **LinkedIn courses search.** `POST /api/linkedin-scraping/search-courses` searches LinkedIn
  Learning and returns course rows (slug, url, title, author, duration, release and viewer text,
  thumbnail). Two filters beyond `keywords`: `difficulty` (`beginner` / `intermediate` /
  `advanced`) and `time_to_complete` (`under_10_mins` through `3_plus_hours`). Send those readable
  values, not LinkedIn's own labels: the translation happens server-side. The software and subject
  facets are not in the filter vocabulary and are reachable by pasting a
  `https://www.linkedin.com/search/results/learning/` url into the same endpoint.

  **LinkedIn groups search.** `POST /api/linkedin-scraping/search-groups` returns group rows (id,
  url, name, privacy, member count, description, logo). Its filter vocabulary is `keywords` and
  nothing else, which is LinkedIn's whole surface for that vertical rather than a subset we chose;
  every other groups facet is reachable by pasting the UI search url into the same endpoint.
</Update>

<Update label="2026-07-29" description="Five new LinkedIn endpoints, seven more come out of stub">
  **253 operations**, up from 248: LinkedIn 155, ID and Teams 77, Orchestration 21.

  **Company reads that stay cheap.** `POST /api/linkedin-enrichment/company-lite-profile` returns
  id, vanity, name and logo, cached 7 days. `POST /api/linkedin-enrichment/company-public-identifier`
  resolves a numeric company id to its vanity slug, cached 30 days. The pattern the two are built
  for: resolve the id once, then address every later company read by vanity. A `/company/{slug}/`
  url is one request on LinkedIn's side, while a Sales Navigator `/sales/company/{id}` url costs
  two, because the vanity has to be resolved first (measured at 433 ms against 2479 ms).

  **A middle person read.** `POST /api/linkedin-enrichment/person-basic-profile` sits between the
  lite identity stub and the full dossier: first and last name as separate fields, headline, country
  and display location, profile imagery, and the premium, influencer, creator and verified flags.
  One request, cached 24 hours. Addressed by `public_identifier` only, so resolve the slug first if
  all you hold is a URN.

  **Services search by pasted url.** The service-providers search gained a url form, so the
  marketplace facets the filter vocabulary does not carry are reachable by pasting a
  `/search/results/services/` url. A url from any other search screen is refused rather than
  silently scraping something else.

  **Editable account display fields.** `PATCH /api/linkedin-accounts/{sid}` sets `display_name` and
  `label`. They are team-authored, never synced from LinkedIn, and an explicit null clears one.

  **Seven more endpoints answer for real**, no longer placeholders: my analytics, my SSI, edit my
  profile, person certifications, person comment activity, person recommendations, and
  service-provider search by filters.

  **Mass actions take nine more step verbs.** The browser lifecycle (run, stop, create, delete, and
  minting or revoking a cloud-browser access key) plus sync-config and smart-limit edits can now be
  plan steps.
</Update>

<Update label="2026-07-28" description="Initial public release of the reference">
  **The public API contract is published.** 248 operations across three services: LinkedIn (150), ID
  and Teams (77), Orchestration (21). The reference is generated from the MCP tool registry, one
  endpoint per tool, and regenerates with it.

  **The MCP server is live** at `mcp.gtm-api.com/mcp`, exposing the same contract as 160+ typed
  tools across 10 toolsets. Connect guides for Claude, Cursor and Docker-based clients are in the
  [MCP tab](/mcp/overview).

  **Webhooks are platform-wide.** Subscriptions, signed deliveries (`X-Webhook-Signature`,
  HMAC-SHA256 with a timestamp), a queryable delivery log, and a test endpoint live on the
  Orchestration service.

  **Mass actions ship with a two-phase consent flow.** Preview validates the whole plan and mints a
  15-minute commit token; commit consumes it. Canary mode, randomized pacing and per-item retry are
  part of the same surface.
</Update>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.