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.

FieldTypeAlways presentDescription
codestringYesMachine-readable error code from the taxonomy below
messagestringYesHuman-readable description
retry_hintobject or nullYesnull when no retry guidance; otherwise {"kind":"delay","delay_ms":N}, {"kind":"backoff"}, or {"kind":"never"}
user_fixablebooleanYestrue when the caller can resolve the error (for example fix a bad parameter, add a seat)
retry_likely_to_succeedbooleanYestrue when retrying the same request may succeed (for example a rate-limit window that will expire)
required_tierstring or absentNoPresent only on TIER_NOT_ACTIVE; values: "core", "sn", "sales_nav", "recruiter"
There is no retry_after_ms field

Wait 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

StatusMeaning
400Bad request. Invalid parameters or body.
401Unauthorized. Missing or invalid API key.
402Payment required. No active subscription, a failed payment, or an expired trial.
403Forbidden. Valid key, but the tier or LinkedIn subscription is missing, or the account lacks permission on the target.
404Not found. The resource does not exist, or is not yours.
409Conflict. The request collides with existing state (already linked, already in progress, duplicate invitation).
413Payload too large. The request body exceeds the size limit.
415Unsupported media type. Wrong Content-Type header.
422Unprocessable. The request is well-formed but LinkedIn will not perform it in the account's current state.
429Rate limited. Quota exceeded (see Rate limits).
500Internal error. Unexpected server-side failure, safe to retry with backoff.
501Not implemented. LinkedIn does not offer this operation for this account type.
502Upstream failure. LinkedIn returned something unusable, safe to retry with backoff.
503Service unavailable. Temporary issue, safe to retry.
504Upstream 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

Always write a default branch

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

CodeHTTPDescription
UNAUTHORIZED401API key missing, malformed, or revoked
INVALID_REQUEST400Request 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_REQUIRED422A 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_TYPE415Content-Type header is missing or not application/json
PAYLOAD_TOO_LARGE413Request body exceeds the maximum allowed size

Account state

CodeHTTPDescription
ACCOUNT_NOT_FOUND404The account_id does not exist or does not belong to this workspace
RESOURCE_NOT_FOUND404A 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_RESTRICTED422The account exists but LinkedIn is restricting it from performing this operation
RESOURCE_ACCESS_RESTRICTED403The account lacks admin or equivalent permission on the target (for example a company page it does not administer)
REAUTH_REQUIRED409The stored session cannot be replayed for this change of scope. Re-authenticate with credentials.

Subscription and tier gating

CodeHTTPDescription
TIER_NOT_ACTIVE403The 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_SUBSCRIBED403The 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

CodeHTTPDescription
RATE_LIMIT_TENANT429The per-workspace rate limit was exceeded. Wait for the Retry-After header.
RATE_LIMIT_ACCOUNT429The per-LinkedIn-account rate limit was exceeded. Wait for the Retry-After header.
PLATFORM_RATE_LIMIT429A platform-level limit was reached. Back off for Retry-After, or a minimum of 60 seconds.
LINKEDIN_RATE_LIMITED429LinkedIn is rate-limiting this account directly. Back off substantially before retrying.

Platform and upstream errors

CodeHTTPDescription
PLATFORM_ERROR502An upstream failure. Retrying after a short backoff is likely to succeed. Note the 502, not 500.
PLATFORM_NOT_IMPLEMENTED501The operation is not offered for this account type or platform tier.
LINKEDIN_OPERATION_NOT_SUPPORTED422LinkedIn structurally disallows this operation or parameter combination, for everyone. Not retryable, and not fixable by subscribing.
LINKEDIN_SERVICE_UNAVAILABLE503LinkedIn is temporarily unavailable. Retry after a backoff.
INTERNAL500An 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).

CodeHTTPDescription
CHECKPOINT_NOT_FOUND404No active checkpoint exists for this account.
CHECKPOINT_EXPIRED422The checkpoint has expired. Restart the connection flow.
CHECKPOINT_INVALID_CODE422The submitted verification code was incorrect.
CHECKPOINT_MAX_ATTEMPTS429Too many incorrect code attempts. Restart the connection flow.
CHECKPOINT_ALREADY_RESOLVED409The checkpoint has already been resolved.
CHECKPOINT_UNSUPPORTED400This challenge type cannot be resolved through the API (for example a CAPTCHA).
CONNECTION_IN_PROGRESS409A connection attempt for this LinkedIn account is already open. Wait for it to finish or expire before starting another.
ACCOUNT_ALREADY_LINKED409This 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_DISABLED403Account linking is disabled on this environment.

LinkedIn session errors

CodeHTTPDescription
LINKEDIN_AUTH_FAILED401LinkedIn rejected the credentials. Verify email and password, then retry.
LINKEDIN_COOKIE_INVALID401The li_at cookie is expired or invalid. Re-export it from your browser.

Messaging and engagement

CodeHTTPDescription
MESSAGE_WINDOW_EXPIRED422The edit or delete window has closed. The message is final; do not retry.
RECIPIENT_UNREACHABLE422The recipient cannot receive a message from this account (no shared connection, privacy settings).
CONNECTION_REQUEST_CONFLICT409A 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_FOUND422No reaction of that value exists to remove. The post exists; your reaction on it does not.

Billing and seats

CodeHTTPDescription
PAYMENT_REQUIRED402No active subscription or available seat. Add one in the dashboard.
PAYMENT_FAILED402A payment attempt failed. Update your payment method in the dashboard.
SUBSCRIPTION_BUSY503The subscription is being modified concurrently. Retry after a short delay; retry_likely_to_succeed is true.
SUBSCRIPTION_NOT_FOUND404No subscription record exists for this workspace.
SEAT_NOT_FOUND404The referenced seat does not exist or is not yours.
SEAT_NOT_EMPTY409The seat already holds a connected account.
SEAT_CANCELLED403The referenced seat has been cancelled.
PREMIUM_CONFLICT400LinkedIn permits only one individual Premium subscription per profile. Use a second seat, or pair the enable with a disable in one call.

Free trial

CodeHTTPDescription
TRIAL_EXPIRED402The trial seat has expired. Buy a seat to continue.
TRIAL_SEAT_LIMIT409A trial provides one seat, and it is already occupied.
TRIAL_ACTIVE_SEAT_LIMIT409Seats cannot be added, toggled, or cancelled while trialing. Convert to a paid plan first.
TRIAL_IDENTITY_ALREADY_USED409That LinkedIn identity has already been used for a trial.
TRIAL_IDENTITY_UNRESOLVED422The 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's code union is narrower than the API's

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));
}
Rate-limit headers need an authenticated request

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

COMPANY · LEGAL

Privacy Policy

Redmer Holding GmbHLast updated August 4, 2026

Who we are

Curviate is operated by Redmer Holding GmbH ("Curviate", "we", "us"), a German GmbH registered at Amtsgericht Bonn, HRB 29957, registered address Hostertstraße 16, 53332 Bornheim, Germany. Full company details are on our Imprint. We haven't appointed a statutory Data Protection Officer, since our processing doesn't reach the scale or sensitivity that requires one. Privacy questions go to privacy@curviate.com.

The two roles we play

When you create an account and use Curviate, we process your own data (identity, billing, API keys, connector authorizations). For that data, we are the controller.

When you use Curviate to act on your own connected LinkedIn account, viewing profiles, sending messages, managing engagement, that content and those contacts belong to that account and its people. You are the controller of that data; we are the processor, acting only on your instructions, under a Data Processing Agreement available on request (see below). If one of your contacts has a question about being reached through Curviate, you're who they should contact first; email privacy@curviate.com if you need help routing it.

What we collect, and why

DataWhy
Account identity (name, email, sign-in method)Create and secure your account
Your LinkedIn credentialsOperate the actions you request
LinkedIn content returned by an API callFulfil that specific request, nothing more
API keys and connector (OAuth) authorizationsAuthenticate your API, CLI, MCP, or SDK requests
Billing detailsCharge you correctly and meet our tax obligations
Usage and security logsKeep the service reliable and abuse-free
Support messagesRespond to you
Website analytics, only if you opt inUnderstand how the site is used

We rely on our contract with you, our legitimate interest in running and securing the service, our legal obligations (tax law, for example), and, for analytics, your consent. We never sell your data or use it to train models.

Where it's processed, and who else touches it

Our infrastructure runs in the EU. Hosting: Railway. Database and auth: Supabase, Ireland. Email: Resend. Payments: Stripe. Network security: a DDoS-protection provider sits in front of our app and never sees or stores request content. LinkedIn connectivity: a third-party infrastructure provider that lets us execute LinkedIn actions on your behalf. Error tracking: Sentry, Frankfurt. Product analytics: PostHog, Frankfurt. Uptime monitoring: Better Stack.

We give the current, named list of every provider above to any customer who asks: security@curviate.com.

Data processing agreement

A data processing agreement under Article 28 of the GDPR is available to business customers on request. Email security@curviate.com and we will send you the current version.

Outside the EU

All customer LinkedIn data, account data, and telemetry are processed and stored exclusively in EU regions of our sub-processors. A few providers we rely on (Stripe and Sentry, for example) are headquartered outside the EU/EEA; where that applies, it's covered by their own GDPR safeguards, typically the EU Standard Contractual Clauses.

How long we keep it

DataRetention
Account and workspace dataWhile your account is active
Closed accountDeleted immediately and irreversibly; see Deleting your account below
LinkedIn credentialsUntil you disconnect that account
LinkedIn contentNot stored; any transient cache clears within 1 hour, never indexed, never used for training
API keysUntil you revoke or rotate them
Connector (OAuth) authorizationsAccess token ~1 hour; refresh token up to ~12 months, or until you revoke it, whichever comes first
Billing recordsAs required by German tax law, currently up to 10 years
LogsA short operational window; metadata only, never message content

The 12-month figure above is a server-side credential for a connected AI agent or app. It is not a cookie and doesn't touch your browser session; see Cookies below for that. You can see and revoke every connector from Authorized applications in your dashboard at any time.

Cookies

We keep cookies to a minimum, and ask before anything beyond the essentials runs.

Strictly necessary, no consent needed:

NamePurposeExpiry
cc_cookieRemembers your cookie choice12 months
curviate-themeRemembers light/dark mode (local storage, not a cookie)Persistent
sb-*-auth-tokenKeeps you signed inWhile active; cleared on sign-out

Analytics, only if you accept:

NamePurposeExpiry
_gaGoogle Analytics: distinguishes visitors2 years
_gidGoogle Analytics: distinguishes visitors24 hours
_ga_<container id>Google Analytics: persists session state2 years

No advertising cookies, ever. Accept and reject are equally easy, and you can change your mind any time via Cookie Preferences in the footer; we won't ask again for 12 months unless something material changes. Our LinkedIn connect flow and OAuth authorization screen never set anything beyond the essentials, so no banner appears there.

Connecting an AI agent or app

Curviate is built for AI agents and automated clients as much as for people. If you connect an app like Claude, or your own code, via an API key or an OAuth connector, it can act on your workspace within the access you gave it. What it does with anything it receives back, including what it sends to its own AI model, is between you and that provider; review its practices before connecting it. Review and revoke any connection any time from your dashboard.

Deleting your account

You can delete your account yourself, from Settings in your dashboard. It takes effect immediately and it cannot be undone. There is no grace period and nothing to restore afterwards, so export anything you want to keep before you start.

Deleting removes your sign-in identity, which frees your email address for reuse straight away, along with your profile, your workspace membership and settings, your API keys, and your seats. For any connected LinkedIn account, we instruct our infrastructure provider to delete it, and your access ends immediately. Records of the connection itself can remain in our systems; email privacy@curviate.com if you need those removed as well. LinkedIn content was never stored in the first place, so there is none of it to delete.

A few things are kept on purpose. We would rather name them than claim a clean sweep:

  • Billing records, for as long as German tax law requires. They hold plan, seat count, amount, and payment references; no name, no email, no LinkedIn data.
  • A record that the deletion happened, so we can show you or a regulator that we did it.
  • A security log of which requests were made, kept for 90 days and then removed automatically. It records that a request happened, never what was in it.
  • A one-way fingerprint, if you used a free trial, that lets us recognise a repeat trial. It holds no readable identifier and cannot be read back into your name, your email, or your LinkedIn profile.

Internal workspace identifiers can also remain in operational records such as queue entries and rate-limit counters. Those carry no name, no email, and no content. If you want to know exactly what is left for your own account, ask us at privacy@curviate.com.

Your rights

You can access, correct, delete, restrict, or object to your data, port it elsewhere, and withdraw consent at any time: email privacy@curviate.com. A copy of your data in a machine-readable format is available on request. We don't make automated decisions about you that have a legal or similarly significant effect. You can also complain to a supervisory authority; ours is the Landesbeauftragte für Datenschutz und Informationsfreiheit Nordrhein-Westfalen (LDI NRW), www.ldi.nrw.de, though you're free to complain to the one in your own country instead.

Keeping it secure

Credentials are encrypted and never logged, returned, or shared. LinkedIn actions run through native, humanized flows; full detail is on our Security & Compliance page. If a breach puts your rights at risk, we'll notify the authorities and you, as GDPR requires. Curviate isn't directed at, or offered to, anyone under 16.

Changes

We'll update this page when our practices change, and reset the cookie prompt if the change is material.

Contact