What's new

Unreleased

Tiers. A Team's subscription now buys one Tier, Build or Automate, and it covers every one of the Team's seats, billed monthly or annually. Build is the complete LinkedIn interface over REST, CLI, MCP and SDK; Automate adds outreach automation on top of it. GET /v1/billing/tier returns the Tier a Team has now, the Tier it bought, its free trial while it runs, and the features included. Any member of the Team can read it, and it needs no connected account.

The free trial runs 7 days, on the Automate Tier, with one connected account and no card.

Breaking: a new connection must say where it connects from. POST /v1/auth/intent without account_id now needs exactly one of country (a supported ISO 3166-1 alpha-2 code such as US; 56 are served, listed as the enum in the reference), ip (a public IPv4 address) or your own proxy. None is 400 CONNECTION_LOCATION_REQUIRED, two is 400 INVALID_REQUEST. Before, a connection that named none was placed wherever our server sits, which is not where your user is, and LinkedIn treats a sign-in from an unexpected country as a risk signal. country and ip are strict by default: when no connection is free in that country the connect fails with 422 CONNECTION_LOCATION_UNAVAILABLE instead of landing elsewhere; send allow_country_fallback: true to allow another country. A reconnect that names no location keeps the account's configured one. proxy.protocol now also accepts socks4.

Breaking: PATCH /v1/accounts/{account_id} returns the account. It used to return { object, account_id }; it now returns the account object (as on GET /v1/accounts/{account_id}, without quotas and event_log). It also takes country and allow_country_fallback to move an account's connection location, and proxy: null now needs a country beside it. Existing accounts are not moved.

A connect can now answer 422 after re-attaching an account you already had. When a new connect lands on a LinkedIn account you previously connected and disconnected, the account is reactivated as before, and the location you sent is then applied to it. If it cannot be (you sent an ip, or no connection is free in that country), the answer is 422 CONNECTION_LOCATION_UNAVAILABLE carrying the account's actual connection_location: the account is connected, and PATCH /v1/accounts/{account_id} with country moves it. This applies to the intent, checkpoint/solve and checkpoint/poll.

New: previous_country. When a connect lands on an account you already had and changes its configured country (to another country, to unknown with ip, or to your own proxy), connection_location.previous_country names the country before. It appears on the intent 200/201 and on the checkpoint/solve and checkpoint/poll completions.

New: connection_location on every account read. GET /v1/accounts, GET /v1/accounts/{account_id}, the connect, reconnect, solve and poll success bodies, and the MCP list_accounts and get_account tools carry connection_location: { country, current_country, mode, strict }.

Breaking: a webhook custom header now reads back as value_fingerprint, and value_prefix is gone. A read of a webhook previously showed the first 8 characters of a custom header value once the value was 24 characters or longer, and a fixed ******** below that. Those 8 characters were stored in the clear alongside the encrypted value, which for a customer-chosen credential is a disclosure we should never have been making. GET /v1/webhooks, GET /v1/webhooks/{id}, POST /v1/webhooks and PATCH /v1/webhooks/{id} now return value_fingerprint on each header instead: 8 hexadecimal characters derived from the value under a key only the server holds. It carries no part of the value and no indication of its length, and it is scoped to your account, so it is not comparable with another account's. It is deterministic, which is the point: the same value always produces the same fingerprint, so it still answers the two questions the prefix was used for, whether a rotation took effect and whether two of your webhooks carry the same credential. It cannot tell you which credential it is at a glance; the header name is what identifies that, and the name is never masked. The request shape is unchanged, you still send { key, value }. A header configured before this change reads value_fingerprint: null until its row is migrated.

Breaking: message.delivered removed from the webhook catalogue. A live paced send between two connected accounts confirmed what a 30-delivery sample had already suggested: LinkedIn never produces a delivery-receipt event, so this event could never fire. It is gone from GET /v1/webhooks/events (messaging now 5 default events instead of 6, 27 catalogued events instead of 28), and POST/PATCH /v1/webhooks now reject it with 400 INVALID_REQUEST if you try to subscribe to it. An existing subscription that still named it had the member removed server side with the 2026-09 deploy, so GET /v1/webhooks no longer echoes it; every other event on the subscription is kept, and a subscription that listed no other event is left with an empty events list. is_delivered on the message resource, readable from both the sender's and the recipient's account context, is the read-path field to check instead; it is set on a successful send and we have not verified whether LinkedIn ever flips it back on a genuine delivery failure.

Stored subscriptions to message.sent, message.failed and connection.rejected are cleaned up. These three events were removed from the webhook catalogue before this changelog existed, and POST/PATCH /v1/webhooks have refused them since. A subscription created before that retirement could still store one of the names, so GET /v1/webhooks kept echoing an event that could never fire. Any such row has had the retired names removed server side; every other event on the subscription is kept, and a subscription that named only a retired event is left with an empty events list.

2026-08: free trial, in-place reconnect, and a wider CLI

data.status on account events now always means one thing: the account's lifecycle status. On an account_status delivery, data.status is the state the account is in once the event has been applied, one of active, connecting, reconnect_needed, restricted, disconnected. It is the same vocabulary GET /v1/accounts/{account_id} returns.

This is a repair, not a breaking change, and it is worth being precise about why. The field was already sending two different values for the same event depending on which internal path noticed the change: a session expiry detected by the real-time push sent "account.reconnect_needed", while the same event detected by the daily reconciliation sent "reconnect_needed", and nothing in the delivery said which. No handler could have been reliably depending on either form; one branching on the event-name form was already failing silently about half the time. Every producer now sends the lifecycle status, so the value is stable per event for the first time.

If you were branching on data.status, switch to the top-level event field (or its guaranteed copy at data.event), which has always carried the canonical event name and is unchanged. The account status events reference carries the complete event to status mapping, and its examples now match what the server sends; several of them previously showed a value no producer ever sent.

account.restricted is subscribable. LinkedIn restricting an account is now an event you can receive. It was previously emitted by the platform but missing from the catalogue, so POST /v1/webhooks rejected it and no subscription could ever match it; a tenant subscribed to every other account_status event was told nothing when an account was restricted. It is in the default set, so an account_status webhook created without an explicit events array now receives it, and no existing subscription changes: events is fixed when the webhook is created. It carries availability: "not_realtime", because a daily reconciliation is what normally notices the restriction (allow up to 24 hours for that path); a connect that observes the account already restricted can also deliver this event immediately, and reading the account directly with GET /v1/accounts/{account_id} always reports the state sooner than either. Treat it as a stop signal and pause outreach for that account.

Free trial. New tenants start on a trial seat. Five trial-specific error codes are now part of the taxonomy: TRIAL_EXPIRED (402), TRIAL_SEAT_LIMIT (409), TRIAL_ACTIVE_SEAT_LIMIT (409), TRIAL_IDENTITY_ALREADY_USED (409), and TRIAL_IDENTITY_UNRESOLVED (422). A trial seat does not raise your rate-limit quota; see Rate limits.

In-place reconnect. POST /v1/auth/intent re-authenticates an existing account when you pass account_id in the body, keeping the id, the seat, and the webhook subscriptions. The CLI exposes it as curviate account link --account-id acc_.... Disconnecting and re-linking is no longer the way to refresh a stale session.

Webhook delivery health now tells you the truth. Three corrections to GET /v1/webhooks, worth reading if you monitor webhook health programmatically.

  • delivery_success_rate_7d is null, not 100, when a webhook has made no delivery attempts in the last 7 days. It previously reported a perfect score for a webhook that had never delivered anything, which read as healthier than one that had failed every attempt. It is documented as a percentage (0-100), which is the scale it has always returned; the reference previously described it as a 0-1 fraction. The values on the wire are unchanged apart from the new null.
  • last_delivery_at now carries the most recent delivery attempt, whether it succeeded or failed. It previously carried only the last successful one, so a webhook that had failed every attempt was indistinguishable from one that had never been used. Together the two fields now separate the cases: null and null means never used; 0 with a timestamp means failing. Note that the rate is windowed to 7 days and the timestamp is not, so a webhook whose only attempts are older than a week returns a null rate alongside a non-null last_delivery_at.
  • last_delivery_at is also now RFC 3339, matching created_at in the same object. It previously emitted the raw database format (2026-07-27 16:11:15.685623+00: a space separator, no Z, an hour-only offset), which strict parsers such as Go's time.RFC3339 and Java's OffsetDateTime.parse reject. JavaScript's new Date() accepts both forms, so lenient clients see no change; anyone strict could not have been parsing this field successfully before. It lands in the same release as the meaning change above, so re-read the field once and you are done.
  • secret_prefix from POST /v1/webhooks is the first 8 characters of the secret, with no trailing .... It now matches what GET /v1/webhooks/{id} and GET /v1/webhooks return for the same webhook, so a stored create value compares equal to a later read.

Deleting a webhook stops delivery immediately. Retries that were already queued for a deleted or disabled webhook are dropped rather than delivered. Previously a deletion left queued attempts to fire for hours afterwards.

Latest clients. @curviate/sdk 0.18.1 and @curviate/cli 0.19.0.

Notable client changes since 0.14.0:

  • CLI inbox search (free-text inbox search) and sales-nav search "<url>" for a pasted Sales Navigator URL.
  • CLI job command family: draft, price, publish, close, and review applicants.
  • CLI profile update, profile follow, profile unfollow, profile following; profile connections renamed profile relations.
  • CLI company-page surface: company managed, company chats, company messages, company search-chats, company reply.
  • CLI inbox sync and inbox sync-chat removed; message history syncs implicitly.
  • CLI message inmail --surface removed; every InMail sends from the account's own credits.

2026-07: webhooks, bigger event catalogue, honest delivery labels

That release took the webhook event catalogue to 27 events, 24 of them subscribable across the messaging, user, and account_status sources, each annotated with a delivery-availability label so you know how an event is delivered before you build on it. See the entry above for the current size.

Delivery availability labels. GET /v1/webhooks/events returns an availability field on the events that are not delivered in real time: not_realtime (delivered on a poll delay of hours, applying to connection.accepted and connection.new) and no_longer_realtime (not delivered on the current platform). account.restricted joined the not_realtime set later; see the entry above. Most events omit the field, which means real-time; the three account.initial_sync.* events carry realtime explicitly. Do not build latency-sensitive logic on anything that is not real-time.

Six new opt-in events. These are additive and never auto-subscribed. Name them explicitly in the events array to receive them:

  • chat.updated, chat.deleted: chat-container state changes (messaging).
  • connection.new: any new relation on the account, not only invitations you sent (user).
  • account.initial_sync.running, account.initial_sync.completed, account.initial_sync.failed: these bracket the initial history backfill for a newly connected account (account_status).

Four account events are no longer delivered. account.synced, account.creation_failed, account.connecting, and account.permission_revoked now carry availability: "no_longer_realtime". For account.creation_failed and account.permission_revoked, detect the state with an on-demand account read (GET /v1/accounts/{account_id}); account.synced and account.connecting have no equivalent read-path signal. Note that create still accepts all four and returns 201; you simply never receive a delivery.

Retrieve a single webhook. GET /v1/webhooks/{id} returns one webhook you own. The plaintext secret is never present on a read, only its 8-character prefix. For health, last_delivery_at, and delivery_success_rate_7d, use GET /v1/webhooks.

The SDK and CLI shipped these changes in 0.14.0.


2026-05: initial release

Curviate API v1 is live. Resource groups: Accounts, Messaging, Profiles, Invites, Search, Posts, Sales Navigator, Recruiter, Webhooks. Core tier available; Sales Navigator and Recruiter add-ons in early access.


Subscribe to release notes at hello@curviate.com.

Next steps