Guides

Custom authentication

Connect your users' LinkedIn accounts from your own product and keep them connected.

Architecture

Your UI collects the sign-in; your backend calls Curviate. Your API key never reaches a browser. Pass credentials straight through instead of storing them: Curviate uses them to connect the Account, never logs them and does not store them, unless you turn on Keep connected, which saves your login encrypted in the EU until you turn it off.

The flow: find a free seat, call POST /v1/auth/intent, walk your user through any checkpoint, then watch account status on a webhook.

Seats

Every connected account occupies one seat. GET /v1/accounts/seats lists them; an item with occupied false is free.

POST /v1/billing/seats/add with qty (1 to 50) buys more and returns the new seat_ids. Every call buys the seats it asks for, so retry only after checking the seat count. A Team on its free trial cannot add seats (TRIAL_ACTIVE_SEAT_LIMIT); buy a seat in the dashboard to convert first.

curl -X POST https://api.curviate.com/v1/billing/seats/add \
  -H "Authorization: Bearer $CURVIATE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"qty": 1}'

Credentials or cookie

Set auth_method:

MethodSend
credentialscredentials: { email, password }. LinkedIn may answer with a checkpoint.
cookiecookie: { li_at } plus a top-level user_agent of the cookie's browser. Add li_a for a Sales Navigator or Recruiter session.

Location: one of country, ip or proxy; strict unless allow_country_fallback: true.

Optional: products (allow-list of sales_navigator and recruiter), and linkedin_premium to pick Recruiter when an account holds both. timezone is deprecated and ignored: the zone follows the connection location.

Start the connect

curl -X POST https://api.curviate.com/v1/auth/intent \
  -H "Authorization: Bearer $CURVIATE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "seat_id": "seat_YOUR_FREE_SEAT_ID",
    "auth_method": "credentials",
    "credentials": { "email": "user@example.com", "password": "..." },
    "country": "US",
    "external_id": "usr_42"
  }'
StatusMeaning
201Connected. Store account_id. recovered true: a disconnected account came back under its original id.
200Reconnected in place (you sent account_id): same id, same seat.
202A checkpoint: challenge_type, expires_at, account_id.
409ACCOUNT_ALREADY_LINKED: reconnect the account_id it names; no id means linked elsewhere, contact support. CONNECTION_IN_PROGRESS: wait. TRIAL_SEAT_LIMIT, TRIAL_IDENTITY_ALREADY_USED, SEAT_CANCELLED: buy a seat.
4xxRefused. See Errors below.

Checkpoints

Every checkpoint call takes the account_id from the 202. A solve can chain into another 202, a poll into a 200 checkpoint_required; handle the new challenge_type the same way.

challenge_typeYour UI showsCall
otpA code field; channel says where it wentPOST /v1/auth/checkpoint/solve with code
two_factor_smsA code field (text message)Solve with code
two_factor_whatsappA code field (WhatsApp)Solve with code
two_factor_appA code field (authenticator app)Solve with code
mobile_app_approval"Approve in your LinkedIn app"POST /v1/auth/checkpoint/poll until it moves on
otp_or_mobile_app_approvalA code field, or app approvalSolve with the code, or poll
checking"Checking your sign-in"Poll
phone_registerA phone fieldSolve with (+CC)number as code; a code follows
contract_selectionA picker over contractsSolve with its id as code
challenge_selectionA picker over challengesPOST /v1/auth/checkpoint/request with the chosen id as challenge

POST /v1/auth/checkpoint/request without challenge re-sends a code that never arrived; resent false means there was nothing to re-send, as with an authenticator app. TRY_ANOTHER_WAY as the code asks for another method, counting no attempt.

curl -X POST https://api.curviate.com/v1/auth/checkpoint/request \
  -H "Authorization: Bearer $CURVIATE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"account_id": "acc_YOUR_ACCOUNT_ID", "challenge": "sms"}'

A CAPTCHA answers 422 with its challenge_type: connect with cookies instead.

Tag accounts with external_id

external_id is your id for the end user who owns the account: 1 to 255 characters, not unique, since one user may connect several accounts. Treat it as opaque. Do not put an email or other personal data in it; use your internal user id.

Set it on the intent, change it with PATCH /v1/accounts/{account_id} (null clears it), and list a user's accounts with GET /v1/accounts?external_id=usr_42. It comes back on account reads, the intent 202, the response that completes the connect, and every account webhook. The same PATCH stores metadata, a flat string map of up to 16 keys, replaced whole on each write.

Account status and the reconnect loop

POST /v1/webhooks with source account_status and no account_ids covers every current and future account; it reads back account_ids null. PATCH /v1/webhooks/{id} with account_ids null switches an existing one.

curl -X POST https://api.curviate.com/v1/webhooks \
  -H "Authorization: Bearer $CURVIATE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"source": "account_status", "request_url": "https://hooks.example.com/curviate"}'

Each event carries account_id, status and external_id, so you route it to your user without a lookup. Verify the signature first.

  • account.reconnect_needed: the session expired or the credentials became invalid. Ask your user to sign in again, then call POST /v1/auth/intent with the account_id and fresh credentials or cookie. Checkpoints work as above.
  • account.restricted: connected, but some actions fail. Tell your user; a reconnect may restore a failing premium product.
  • account.disconnected: the account cannot act until reconnected.

GET /v1/accounts/{account_id} always has the current status. All events: Account status events.

Offboarding

DELETE /v1/accounts/{account_id} disconnects and deletes the account and frees its seat; a repeat call returns 200 with already_disconnected. Reuse the seat, or stop paying for it with POST /v1/billing/seats/{seat_id}/cancel, which ends it at effective_at, the end of the current period. POST /v1/billing/seats/{seat_id}/cancel/revert undoes that before then.

Errors

Branch on code. Full list: Errors.

codeFix
INVALID_REQUESTFix the field the message names: often the auth block at the top level, no seat_id, or a cookie without user_agent.
SEAT_NOT_FOUNDRe-read GET /v1/accounts/seats.
PAYMENT_REQUIREDBuy a seat or, if past due, update the payment method.
LINKEDIN_AUTH_FAILEDLinkedIn rejected the sign-in (on a reconnect, a cookie too): ask for fresh credentials or cookie.
LINKEDIN_COOKIE_INVALIDA new connect's li_at is invalid: get a fresh one.
LINKEDIN_FEATURE_NOT_SUBSCRIBEDNarrow products, or set linkedin_premium to the premium the account holds.
ACCOUNT_RESTRICTEDYour user resolves it on LinkedIn, then you connect again.
CHECKPOINT_INVALID_CODELet your user retry the code.
CHECKPOINT_EXPIRED, CHECKPOINT_MAX_ATTEMPTSStart a new intent.
CHECKPOINT_NOT_FOUNDRead the account; the connect may have finished.
TRIAL_ACTIVE_SEAT_LIMITConvert to paid in the dashboard, then change seats.