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:
| Method | Send |
|---|---|
credentials | credentials: { email, password }. LinkedIn may answer with a checkpoint. |
cookie | cookie: { li_at } plus a top-level user_agent of the cookie's browser. Add li_a for a Sales Navigator or Recruiter session. |
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": "..." },
"external_id": "usr_42"
}'| Status | Meaning |
|---|---|
| 201 | Connected. Store account_id. recovered true: a disconnected account came back under its original id. |
| 200 | Reconnected in place (you sent account_id): same id, same seat. |
| 202 | A checkpoint: challenge_type, expires_at, account_id. |
| 409 | ACCOUNT_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. |
| 4xx | Refused. 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_type | Your UI shows | Call |
|---|---|---|
otp | A code field | POST /v1/auth/checkpoint/solve with code |
two_factor_sms | A code field (text message) | Solve with code |
two_factor_whatsapp | A code field (WhatsApp) | Solve with code |
two_factor_app | A 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_approval | A code field, or app approval | Solve with the code, or poll |
contract_selection | A picker over contracts | Solve with the chosen contract id as code |
challenge_selection | A 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 callPOST /v1/auth/intentwith theaccount_idand 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.
code | Fix |
|---|---|
INVALID_REQUEST | Fix the field the message names: often the auth block at the top level, no seat_id, or a cookie without user_agent. |
SEAT_NOT_FOUND | Re-read GET /v1/accounts/seats. |
PAYMENT_REQUIRED | Buy a seat or, if past due, update the payment method. |
LINKEDIN_AUTH_FAILED | LinkedIn rejected the sign-in (on a reconnect, a cookie too): ask for fresh credentials or cookie. |
LINKEDIN_COOKIE_INVALID | A new connect's li_at is invalid: get a fresh one. |
LINKEDIN_FEATURE_NOT_SUBSCRIBED | Narrow products, or set linkedin_premium to the premium the account holds. |
ACCOUNT_RESTRICTED | Your user resolves it on LinkedIn, then you connect again. |
CHECKPOINT_INVALID_CODE | Let your user retry the code. |
CHECKPOINT_EXPIRED, CHECKPOINT_MAX_ATTEMPTS | Start a new intent. |
CHECKPOINT_NOT_FOUND | Read the account; the connect may have finished. |
TRIAL_ACTIVE_SEAT_LIMIT | Convert to paid in the dashboard, then change seats. |