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 encrypts them at rest and never logs them.

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 workspace 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: timezone (an IANA name such as Europe/Berlin; an offset is a 400), products (allow-list of sales_navigator and recruiter), and linkedin_premium to pick Recruiter when an account holds both.

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 fieldPOST /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 while pending; stop on expired or failed
otp_or_mobile_app_approvalA code field, or app approvalSolve with the code, or poll
contract_selectionA picker over contractsSolve with the chosen contract id as code
challenge_selectionA picker over challenges (label, masked destination in description)POST /v1/auth/checkpoint/request with challenge set to the chosen id

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. Solving with the code TRY_ANOTHER_WAY switches to another method.

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 or a phone-number registration cannot be completed through the API: the connect returns 422, with a challenge_type naming which one, so your UI can tell the user what LinkedIn wants.

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.