Errors
Every error response from the Curviate API uses the same flat JSON envelope. Machine-readable codes let your agent handle failures programmatically.
Error envelope
{
"code": "UNAUTHORIZED",
"message": "Valid API key required. Use Authorization: Bearer cvt_live_<key>.",
"retry_hint": null,
"user_fixable": true,
"retry_likely_to_succeed": false
}The error body is a flat JSON object; there is no wrapper "error" key. The top-level properties are code, message, retry_hint, user_fixable, retry_likely_to_succeed, and optionally required_tier.
| Field | Type | Always present | Description |
|---|---|---|---|
code | string | Yes | Machine-readable error code from the taxonomy below |
message | string | Yes | Human-readable description |
retry_hint | object or null | Yes | null when no retry guidance; otherwise {"kind":"delay","delay_ms":N}, {"kind":"backoff"}, or {"kind":"never"} |
user_fixable | boolean | Yes | true when the caller can resolve the error (for example fix a bad parameter, add a seat) |
retry_likely_to_succeed | boolean | Yes | true when retrying the same request may succeed (for example a rate-limit window that will expire) |
required_tier | string or absent | No | Present only on TIER_NOT_ACTIVE; values: "core", "sn", "sales_nav", "recruiter" |
retry_after_ms fieldWait time arrives in two places and neither is called retry_after_ms:
the Retry-After response header (IETF, seconds), and
the optional retry_hint.delay_ms in the body
(milliseconds, snake_case). Reading a field that does not exist
yields undefined, and arithmetic on it yields NaN, which
typically collapses your backoff to zero and retries straight back into an active
limit. The TypeScript SDK exposes the same value camelCased, as
err.retryHint?.delayMs.
HTTP status codes
| Status | Meaning |
|---|---|
400 | Bad request. Invalid parameters or body. |
401 | Unauthorized. Missing or invalid API key. |
402 | Payment required. No active subscription, a failed payment, or an expired trial. |
403 | Forbidden. Valid key, but the tier or LinkedIn subscription is missing, or the account lacks permission on the target. |
404 | Not found. The resource does not exist, or is not yours. |
409 | Conflict. The request collides with existing state (already linked, already in progress, duplicate invitation). |
413 | Payload too large. The request body exceeds the size limit. |
415 | Unsupported media type. Wrong Content-Type header. |
422 | Unprocessable. The request is well-formed but LinkedIn will not perform it in the account's current state. |
429 | Rate limited. Quota exceeded (see Rate limits). |
500 | Internal error. Unexpected server-side failure, safe to retry with backoff. |
501 | Not implemented. LinkedIn does not offer this operation for this account type. |
502 | Upstream failure. LinkedIn returned something unusable, safe to retry with backoff. |
503 | Service unavailable. Temporary issue, safe to retry. |
504 | Upstream timeout. Safe to retry with backoff. |
422 and 502 are the two statuses integrators most often forget to handle. 422 carries most of the "LinkedIn said no" outcomes, and 502 is where an upstream failure surfaces, not 500.
Error codes
The tables below cover the codes you are most likely to meet, not all of them,
and the taxonomy is explicitly additive: new codes are appended over time and
existing ones are never removed. A switch over error.code with no default
will silently fall through on a code added after you shipped. Branch on the
codes you handle, and treat everything else as "unknown failure, surface it".
Authentication and request validation
| Code | HTTP | Description |
|---|---|---|
UNAUTHORIZED | 401 | API key missing, malformed, or revoked |
INVALID_REQUEST | 400 | Request body or parameters failed schema validation. The message names the offending field. A body field the operation does not declare is rejected here too, never accepted and quietly discarded, so a filter you send is either applied or reported; operations that reject unknown keys carry additionalProperties: false in the OpenAPI spec. A common cause is a query parameter such as limit or cursor placed in the JSON body. On a structured search a filter value that matches no filter option is NOT an error: it is sent on as an id and reported in notices[] on the 200. |
FILTER_CANDIDATES_REQUIRED | 422 | A structured search filter value matched several options, so it cannot be resolved to one id without your choice. unresolved[] lists each offending field with its candidates, and next_action says what to do. Re-send with a chosen id. See Search. |
UNSUPPORTED_MEDIA_TYPE | 415 | Content-Type header is missing or not application/json |
PAYLOAD_TOO_LARGE | 413 | Request body exceeds the maximum allowed size |
Account state
| Code | HTTP | Description |
|---|---|---|
ACCOUNT_NOT_FOUND | 404 | The account_id does not exist or does not belong to this workspace |
RESOURCE_NOT_FOUND | 404 | A non-account resource (chat, message, invitation, webhook) was not found. Also what a mistyped path returns, so check the URL shape before you check your ids. |
ACCOUNT_RESTRICTED | 422 | The account exists but LinkedIn is restricting it from performing this operation |
RESOURCE_ACCESS_RESTRICTED | 403 | The account lacks admin or equivalent permission on the target (for example a company page it does not administer) |
REAUTH_REQUIRED | 409 | The stored session cannot be replayed for this change of scope. Re-authenticate with credentials. |
Subscription and tier gating
| Code | HTTP | Description |
|---|---|---|
TIER_NOT_ACTIVE | 403 | The endpoint requires a tier add-on that is not active on this account's seat. The required_tier field names the needed tier (sn, sales_nav, or recruiter). |
LINKEDIN_FEATURE_NOT_SUBSCRIBED | 403 | The LinkedIn account itself does not have the premium feature the endpoint requires (InMail, Sales Navigator, Recruiter). Distinct from TIER_NOT_ACTIVE, which is the Curviate-side seat gate. |
Rate limits
| Code | HTTP | Description |
|---|---|---|
RATE_LIMIT_TENANT | 429 | The per-workspace rate limit was exceeded. Wait for the Retry-After header. |
RATE_LIMIT_ACCOUNT | 429 | The per-LinkedIn-account rate limit was exceeded. Wait for the Retry-After header. |
PLATFORM_RATE_LIMIT | 429 | A platform-level limit was reached. Back off for Retry-After, or a minimum of 60 seconds. |
LINKEDIN_RATE_LIMITED | 429 | LinkedIn is rate-limiting this account directly. Back off substantially before retrying. |
Platform and upstream errors
| Code | HTTP | Description |
|---|---|---|
PLATFORM_ERROR | 502 | An upstream failure. Retrying after a short backoff is likely to succeed. Note the 502, not 500. |
PLATFORM_NOT_IMPLEMENTED | 501 | The operation is not offered for this account type or platform tier. |
LINKEDIN_OPERATION_NOT_SUPPORTED | 422 | LinkedIn structurally disallows this operation or parameter combination, for everyone. Not retryable, and not fixable by subscribing. |
LINKEDIN_SERVICE_UNAVAILABLE | 503 | LinkedIn is temporarily unavailable. Retry after a backoff. |
INTERNAL | 500 | An unexpected internal failure. Safe to retry with exponential backoff. |
Account connection (checkpoint)
These codes appear during the account-connect flow (POST /v1/auth/intent, POST /v1/auth/checkpoint/solve).
| Code | HTTP | Description |
|---|---|---|
CHECKPOINT_NOT_FOUND | 404 | No active checkpoint exists for this account. |
CHECKPOINT_EXPIRED | 422 | The checkpoint has expired. Restart the connection flow. |
CHECKPOINT_INVALID_CODE | 422 | The submitted verification code was incorrect. |
CHECKPOINT_MAX_ATTEMPTS | 429 | Too many incorrect code attempts. Restart the connection flow. |
CHECKPOINT_ALREADY_RESOLVED | 409 | The checkpoint has already been resolved. |
CHECKPOINT_UNSUPPORTED | 400 | This challenge type cannot be resolved through the API (for example a CAPTCHA). |
CONNECTION_IN_PROGRESS | 409 | A connection attempt for this LinkedIn account is already open. Wait for it to finish or expire before starting another. |
ACCOUNT_ALREADY_LINKED | 409 | This LinkedIn identity is already linked. When your workspace already owns it, the error names your own account_id; re-authenticate that account in place rather than linking again. Otherwise no id is named, because the identity is not yours to act on. A connect that resolves by reactivating an account you had previously disconnected returns that account with recovered: true instead of this error. |
ACCOUNT_LINKING_DISABLED | 403 | Account linking is disabled on this environment. |
LinkedIn session errors
| Code | HTTP | Description |
|---|---|---|
LINKEDIN_AUTH_FAILED | 401 | LinkedIn rejected the credentials. Verify email and password, then retry. |
LINKEDIN_COOKIE_INVALID | 401 | The li_at cookie is expired or invalid. Re-export it from your browser. |
Messaging and engagement
| Code | HTTP | Description |
|---|---|---|
MESSAGE_WINDOW_EXPIRED | 422 | The edit or delete window has closed. The message is final; do not retry. |
RECIPIENT_UNREACHABLE | 422 | The recipient cannot receive a message from this account (no shared connection, privacy settings). |
CONNECTION_REQUEST_CONFLICT | 409 | A request to this member is already pending, or you are already connected. Never retry this: a send-withdraw-resend loop is exactly the pattern that gets an account flagged. |
REACTION_NOT_FOUND | 422 | No reaction of that value exists to remove. The post exists; your reaction on it does not. |
Billing and seats
| Code | HTTP | Description |
|---|---|---|
PAYMENT_REQUIRED | 402 | No active subscription or available seat. Add one in the dashboard. |
PAYMENT_FAILED | 402 | A payment attempt failed. Update your payment method in the dashboard. |
SUBSCRIPTION_BUSY | 503 | The subscription is being modified concurrently. Retry after a short delay; retry_likely_to_succeed is true. |
SUBSCRIPTION_NOT_FOUND | 404 | No subscription record exists for this workspace. |
SEAT_NOT_FOUND | 404 | The referenced seat does not exist or is not yours. |
SEAT_NOT_EMPTY | 409 | The seat already holds a connected account. |
SEAT_CANCELLED | 403 | The referenced seat has been cancelled. |
PREMIUM_CONFLICT | 400 | LinkedIn permits only one individual Premium subscription per profile. Use a second seat, or pair the enable with a disable in one call. |
Free trial
| Code | HTTP | Description |
|---|---|---|
TRIAL_EXPIRED | 402 | The trial seat has expired. Buy a seat to continue. |
TRIAL_SEAT_LIMIT | 409 | A trial provides one seat, and it is already occupied. |
TRIAL_ACTIVE_SEAT_LIMIT | 409 | Seats cannot be added, toggled, or cancelled while trialing. Convert to a paid plan first. |
TRIAL_IDENTITY_ALREADY_USED | 409 | That LinkedIn identity has already been used for a trial. |
TRIAL_IDENTITY_UNRESOLVED | 422 | The trial could not be completed because the member identity could not be resolved. |
A brand-new trial customer meets the TRIAL_* codes before almost anything else, so handle TRIAL_EXPIRED and TRIAL_SEAT_LIMIT explicitly if you onboard trial users.
SDK: CurviateError
When using the TypeScript SDK, every API-layer failure throws a CurviateError. The properties are camelCased: code, message, retryHint (with retryHint.delayMs), userFixable, retryLikelyToSucceed, requiredTier, and httpStatus.
The SDK ships a fixed union of error codes and maps anything outside it to
INTERNAL before your switch sees it. Codes added to the
API after an SDK release therefore arrive as INTERNAL. Read
err.httpStatus and err.message alongside
err.code when a failure does not match what you expected, and keep the
REST envelope above as the authoritative list.
import { Curviate, isCurviateError } from "@curviate/sdk";
const curviate = new Curviate({ apiKey: process.env.CURVIATE_API_KEY! });
try {
await curviate.accounts.list();
} catch (err) {
if (!isCurviateError(err)) throw err;
switch (err.code) {
case "RATE_LIMIT_TENANT":
case "RATE_LIMIT_ACCOUNT":
// delayMs is the camelCase view of retry_hint.delay_ms
await new Promise((r) => setTimeout(r, err.retryHint?.delayMs ?? 60_000));
break;
case "UNAUTHORIZED":
console.error("Check your API key.");
break;
case "ACCOUNT_NOT_FOUND":
console.error("Run accounts.list() and use an id from that response.");
break;
default:
// Required: the taxonomy is additive, so unknown codes will appear.
console.error(`Unhandled ${err.code} (HTTP ${err.httpStatus}): ${err.message}`);
throw err;
}
}Handling a 429
# A 429 response, rate limit exceeded
HTTP/2 429
Retry-After: 37
ratelimit: "tenant";r=0;t=37
ratelimit-policy: "tenant";q=2500;w=60
Content-Type: application/json
{
"code": "RATE_LIMIT_TENANT",
"message": "Tenant rate limit exceeded.",
"retry_hint": { "kind": "delay", "delay_ms": 37000 },
"user_fixable": false,
"retry_likely_to_succeed": true
}Read the wait from Retry-After (seconds) or from retry_hint.delay_ms (milliseconds); they carry the same value in different units. Retry-After is the canonical one, and it is present on every 429.
const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
if (res.status === 429) {
const header = res.headers.get("retry-after");
const body = await res.json();
const waitMs =
(header ? Number(header) * 1000 : undefined) ??
body.retry_hint?.delay_ms ??
60_000;
await new Promise((r) => setTimeout(r, waitMs));
}ratelimit and ratelimit-policy are returned on every
authenticated response. A 401 carries none, because
the limiter runs after authentication. Do not treat their absence as "no limit
applies"; treat it as "this request never authenticated".
Next steps
- Rate limits: the quota formula, the headers, and a worked backoff client.
- Getting started guides: the four calls most integrations start with.
- API reference: the per-endpoint error responses.