Credentials Flow

Integrate authentication for provider that accepts a set of credentials—such as a username and password, tokens, or connection parameters. During this process, the provider may also require the user to solve additional challenges like Two-Factor Authentication (2FA), One-Time Passwords (OTP), CAPTCHAs, or in-app validations.

📘

This flow is used by LinkedIn, IMAP, Instagram, Facebook, and X (Twitter)


To authenticate users using the credentials flow:

  1. Create a login form to collect the credentials required by the provider.
  2. Set up a route to handle form submissions. This route should call the Start Auth Intent endpoint, then:
    • Display a success screen if the account is successfully authenticated.
    • Redirect to a checkpoint screen if additional verification is required.
  3. Implement a route to handle checkpoint resolution using the Solve a Checkpoint endpoint.
  4. Build a checkpoint screen to guide the user through the required step — such as entering a verification code, solving a CAPTCHA, or waiting for in-app validation.

To reconnect an existing account, just use the exact same flow but provide the Account ID along with the user credentials in the Start Auth Intent endpoint.


Collect credentials



In your local application, implement a login form that collects the required credentials for the chosen provider. You can refer to the credentials field in the Start Auth Intent endpoint body to determine which inputs are necessary.

Next, create a new route in your application to handle the form submission via a POST request. This route should:

  • Start Auth Intent : Forward the credentials submitted through the form to initiate the authentication process.
  • Handle Successful Authentication : In the best case scenario, the authentication is successful directly, which returns an Account object. Redirect the user to a confirmation screen or display a success message.
  • Handle Errors gracefully :
    • Error responses addressed to the account owner:
      • provider/invalid_credentials for invalid credentials
      • provider/unknown_authentication_context if the process require the user to authenticate on the provider's website
      • For IMAP Accounts, api/invalid_parameters if Unipile could not detect automatically the full configuration. Read Link an Email account to learn how to handle this case.
      • If the user has given a custom proxy, all proxy related errors, such asapi/proxy_auth_error for invalid proxy credentials, and api/proxy_error or api/proxy_timeout if the proxy is not working properly.
    • Duplicate and Restricted Accounts errors as mentioned in Accounts Linking Limitations.
    • Other critical errors

Server code

// index.ts

import express, { Request, Response } from "express";
import cors from "cors";
import parser from "body-parser";
import "dotenv/config";

import { UnipileCustomAuth, UnipileAccounts } from "unipile";

const key = process.env.UNIPILE_API_KEY ?? "";

const customAuthApi = new UnipileCustomAuth({ key });
const accountsApi = new UnipileAccounts({ key });

const app = express();

app.use(cors());

app.use(parser.json());
app.use(parser.urlencoded({ extended: true }));
app.use(express.json());

app.post("/auth-intent", async (req: Request, res: Response) => {
  const response = await customAuthApi.startAuthIntent({
    body: req.body,
  });

  // Handle Errors
  if (response.error) {
    res.status(response.response.status).send(response.error);
    return;
  }

  res.send(response.data);
});

app.listen(3000, () => {
  console.log("Server is running on port 3000");
});
import json
import os
from typing import Any, Optional

import unipile
import uvicorn
from dotenv import load_dotenv
from fastapi import Body, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, PlainTextResponse, Response
from unipile.api.accounts_api import AccountsApi
from unipile.api.custom_auth_api import CustomAuthApi
from unipile.exceptions import ApiException

load_dotenv()

key = os.getenv("UNIPILE_API_KEY", "")

configuration = unipile.Configuration()
if key:
    configuration.api_key["apiKey"] = key
api_client = unipile.ApiClient(configuration)

custom_auth_api = CustomAuthApi(api_client)
accounts_api = AccountsApi(api_client)

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


def _to_response_data(value: Any) -> Any:
    if hasattr(value, "to_dict") and callable(value.to_dict):
        return value.to_dict()
    return value


def _api_exception_to_body(exception: ApiException) -> Any:
    if exception.data is not None:
        return _to_response_data(exception.data)

    if exception.body:
        try:
            return json.loads(exception.body)
        except json.JSONDecodeError:
            return exception.body

    return exception.reason or "Unknown error"


def _error_response(exception: ApiException) -> Response:
    status_code = exception.status or 500
    body = _api_exception_to_body(exception)
    if isinstance(body, (dict, list)):
        return JSONResponse(status_code=status_code, content=body)
    return PlainTextResponse(status_code=status_code, content=str(body))


@app.post("/auth-intent")
def auth_intent(body: Any = Body(...)) -> Response:
    try:
        params = custom_auth_api._start_auth_intent_serialize(
            start_auth_intent_request=body,
            _request_auth=None,
            _content_type=None,
            _headers=None,
            _host_index=0,
        )
        response_data = custom_auth_api.api_client.call_api(*params)
        response_data.read()
        response = custom_auth_api.api_client.response_deserialize(
            response_data=response_data,
            response_types_map={"200": "SolveCheckpoint200Response"},
        ).data
        return JSONResponse(content=_to_response_data(response))
    except ApiException as exception:
return _error_response(exception)

if __name__ == "__main__":
    uvicorn.run("src.main:app", host="0.0.0.0", port=4000, reload=True)

curl --request POST \
     --url https://api.unipile.com/v2/auth/intent \
     --header 'X-API-KEY: api-key' \
     --header 'accept: application/json' \
     --header 'content-type: application/json' \
     --data '
{
  "provider": "linkedin",
  "credentials": {
    "username": "username",
    "password": "password"
  }
}
'

Client code

// lib/unipile.ts

import type {
  StartAuthIntentData,
  StartAuthIntentResponse,
} from "unipile";

export async function startAuthIntent(body: StartAuthIntentData) {
  const response = await fetch("http://localhost:4000/auth-intent", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(body),
  });

  if (!response.ok) {
    const error = await response.text();
    throw new Error(error);
  }

  const data = await response.json();

  return data as StartAuthIntentResponse;
}

// components/LinkedIn.tsx

import type { StartAuthIntentResponse } from "unipile";
import { CredentialsFlowContainer } from "./containers/CredentialsFlowContainer";
import { startAuthIntent } from "../../lib/unipile";
import { useState } from "react";

export function LinkedInLogin({
  onSuccess,
}: {
  onSuccess: (data: StartAuthIntentResponse) => void;
}) {
  const [status, setStatus] = useState<
    "idle" | "loading" | "success" | "error"
  >("idle");

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.target as HTMLFormElement);
    const username = formData.get("username") as string;
    const password = formData.get("password") as string;

    setStatus("loading");

    try {
      const data = await startAuthIntent({
        provider: "linkedin",
        credentials: {
          username,
          password,
        },
      });

      setStatus("success");
      onSuccess(data);
    } catch (e) {
      console.error(e);
      setStatus("error");
    }
  };

  return (
    <div>
      <h1>Login to LinkedIn</h1>
      <form onSubmit={handleSubmit}>
        <input type="text" placeholder="Username" name="username" />
        <input type="password" placeholder="Password" name="password" />
        <button type="submit" disabled={status === "loading"}>
          {status === "loading" ? "Loading..." : "Login"}
        </button>
      </form>
      {status === "error" && <p>Error</p>}
    </div>
  );
}

export function LinkedIn() {
  return (
    <CredentialsFlowContainer>
      {(handleSuccess) => <LinkedInLogin onSuccess={handleSuccess} />}
    </CredentialsFlowContainer>
  );
}




// components/containers/CredentialsFlowContainer.tsx

import { useState } from "react";
import type {
  GetAccountResponse,
  StartAuthIntentResponse,
} from "unipile";
import { LinkedInLogin } from "./logins/LinkedInLogin";

export function CredentialsFlowContainer({ children }: {
  children: (
    handleSuccess: (data: StartAuthIntentResponse) => void
  ) => React.ReactNode;
}) {
  const [account, setAccount] = useState<GetAccountResponse>();

  const handleSuccess = (data: StartAuthIntentResponse) => {
    if (data.object === "Account") setAccount(data);
  };

  if (account) {
    return (
      <h1>
        Account {account.name || account.id} is successfully authenticated
      </h1>
    );
  }

  return children(handleSuccess);
}

// App.tsx

import { LinkedIn } from "./components/LinkedIn";

export function App() {
  return <LinkedIn />
}


Redirect to a Checkpoint screen

After initiating an Auth Intent, an AuthenticationCheckpoint may be returned. This checkpoint must be resolved by the user before the authentication process can continue. Different providers may require different types of verification, which your application must support :

  • Two-Factor Authentication (2FA) : If the account is protected by 2FA, the user will be prompted to enter a code from their authentication app (e.g., Google Authenticator) or SMS.
  • One Time Password (OTP) : To verify the user’s identity, a code may be sent to their registered email address or phone number. The user must enter this code to proceed.
  • Phone registration (PHONE_REGISTER) : If no phone number is registered with the provider, the user will first be asked to enter a valid phone number. Once submitted, a follow-up checkpoint will prompt them to enter a verification code sent to that number.
  • Captcha (CAPTCHA) : To ensure the user is human, the provider may require solving a CAPTCHA. Your application should display the CAPTCHA challenge for the user to complete.
  • In App Validation (IN_APP_VALIDATION) : Some providers use their own mobile apps to verify the user’s identity. In this case, the user will need to confirm the login request by interacting with the provider’s app. Your application must wait for this validation to complete.

📘

No Checkpoint Providers

The following providers do not return checkpoints:

  • IMAP

If you only plan to link accounts from those providers using the Credentials Flow, you can stop the tutorial here.


The given AuthenticationCheckpoint object contains useful metadata that will help you adapt the UI and that must be forwarded to solve the checkpoint later on:

  • intent_id is required to be used in the Solve a Checkpoint request later.
  • checkpoint.type and checkpoint.platform can be used to display a contextual title / description to indicate what code is expected and where it can be found, or what action is required from the user.
  • For CAPTCHA checkpoint, checkpoint.public_key and checkpoint.data are required to display the Captcha client side.

Update your client code to handle a checkpoint response.

Client code

// components/containers/CredentialsFlowContainer.tsx

// ...
import type {
  SolveCheckpointResponse,
  StartAuthIntentResponse,
  GetAccountResponse,
} from "unipile";
import { CodeInputCheckpoint } from "../checkpoints/CodeInputCheckpoint";
import { CaptchaCheckpoint } from "../checkpoints/CaptchaCheckpoint";
import { ActionCheckpoint } from "../checkpoints/ActionCheckpoint";

type AuthenticationCheckpoint = SolveCheckpointResponse & {
  object: "AuthenticationCheckpoint";
};


type AuthenticationCheckpoint = SolveCheckpointResponse & {
  object: "AuthenticationCheckpoint";
};

export function CredentialsFlowContainer({ provider }: { provider: string }) {
  const [checkpoint, setCheckpoint] = useState<AuthenticationCheckpoint>();
  const [account, setAccount] = useState<GetAccountResponse>();

  const handleSuccess = (data: StartAuthIntentResponse) => {
    if (data.object === "AuthenticationCheckpoint") setCheckpoint(data);
    if (data.object === "Account") setAccount(data);
  };
  
  if (checkpoint) {
    const { type } = checkpoint.checkpoint;

    if (type === "2FA") {
      return (
        <CodeInputCheckpoint
          title={"Two-Factor Authentication (" + checkpoint.checkpoint.platform + ")"}
          placeholder="Code"
          intent_id={checkpoint.intent_id}
          onSuccess={handleSuccess}
        />
      );
    }

    if (type === "OTP") {
      return (
        <CodeInputCheckpoint
          title="A One-Time Password has been sent to your email"
          placeholder="Code"
          intent_id={checkpoint.intent_id}
          onSuccess={handleSuccess}
        />
      );
    }

    if (type === "PHONE_REGISTER") {
      return (
        <CodeInputCheckpoint
          title="Please provide your phone number"
          placeholder="Phone Number"
          intent_id={checkpoint.intent_id}
          onSuccess={handleSuccess}
        />
      );
    }

    if (type === "CAPTCHA") {
      return (
        <CaptchaCheckpoint
          intent_id={checkpoint.intent_id}
          data={checkpoint.checkpoint.data || ""}
          public_key={checkpoint.checkpoint.public_key || ""}
          onSuccess={handleSuccess}
        />
      );
    }

    if (type === "IN_APP_VALIDATION") {
      return (
        <ActionCheckpoint
          title="Validate your identity from the provider's app"
          intent_id={checkpoint.intent_id}
          onSuccess={handleSuccess}
        />
      );
    }
  }

//...

}



Handle checkpoint solving

Before implementing the client code, create a POST route in your application to handle various forms of checkpoint solving.

This route should:

  • Solve a Checkpoint: Forward the code that could be submitted by code-based checkpoints with the intent_id provided in the AuthenticationCheckpoint object.
  • Handle Success or Further Checkpoints : Treat the response exactly as you would in the previous steps: either complete the login flow or handle additional chained checkpoints.
  • Handle Errors gracefully the same way as in the first step of this guide (Collect credentials).

Server code

// index.ts

// ...

app.post("/solve-checkpoint", async (req: Request, res: Response) => {
  const response = await customAuthApi.solveCheckpoint({
    body: req.body,
  });

  // Handle Errors
  if (response.error) {
    res.status(response.response.status).send(response.error);
    return;
  }

  // Return the Account or AuthenticationCheckpoint
  res.send(response.data);
});

//...
curl --request POST \
     --url https://api.unipile.com/v2/auth/checkpoint \
     --header 'X-API-KEY: api-key' \
     --header 'accept: application/json' \
     --header 'content-type: application/json' \
     --data '
{
  "code": "123",
  "intent_id": "acc_123456789"
}
'
# main.py

# ...

@app.post("/solve-checkpoint")
def solve_checkpoint(body: Any = Body(...)) -> Response:
    try:
        params = custom_auth_api._solve_checkpoint_serialize(
            solve_checkpoint_request=body,
            _request_auth=None,
            _content_type=None,
            _headers=None,
            _host_index=0,
        )
        response_data = custom_auth_api.api_client.call_api(*params)
        response_data.read()
        response = custom_auth_api.api_client.response_deserialize(
            response_data=response_data,
            response_types_map={"200": "SolveCheckpoint200Response"},
        ).data
        return JSONResponse(content=_to_response_data(response))
    except ApiException as exception:
      return _error_response(exception)
    
# ...

Client code

// lib/unipile.ts

import type {
  SolveCheckpointData,
  SolveCheckpointResponse,
} from "unipile";

//...

export async function solveCheckpoint(body: SolveCheckpointData) {
  const response = await fetch("http://localhost:4000/solve-checkpoint", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(body),
  });

  if (!response.ok) {
    const error = await response.text();
    throw new Error(error);
  }

  const data = await response.json();

  return data as SolveCheckpointResponse;
}

Present code-based checkpoints

Some checkpoints only require a code to be solved. This code is either:

  • Manually entered by the user (in the case of Two-Factor Authentication, One-Time Passwords, or Phone Registration — where the “code” is a phone number),
  • Or generated as a result of solving a CAPTCHA.

Once collected, this code must be forwarded to the Solve a Checkpoint endpoint using the previously created route.



Display code input

For checkpoints of type 2FA, OTP, or PHONE_REGISTER, implement a form with an input field to collect the required code / phone number. The checkpoint.type and checkpoint.platform can be used to display a contextual title / description to indicate what code is expected and where it can be found.

On submit of the form, make a POST request to the previously created route.


Client code

// components/checkpoints/CodeInputCheckpoint.tsx

import type { SolveCheckpointResponse } from "unipile";
import { solveCheckpoint } from "../../lib/unipile";

export function CodeInputCheckpoint({
  title,
  placeholder,
  intent_id,
  onSuccess,
}: {
  title: string;
  placeholder: string;
  intent_id: string;
  onSuccess: (data: SolveCheckpointResponse) => void;
}) {
  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.target as HTMLFormElement);
    const code = formData.get("code") as string;

    const data = await solveCheckpoint({
      intent_id,
      code,
    });

    onSuccess(data);
  };

  return (
    <div>
      <h1>{title}</h1>
      <form onSubmit={handleSubmit}>
        <input type="text" placeholder={placeholder} name="code" />
        <button type="submit">Submit</button>
      </form>
    </div>
  );
}

Display captcha

For CAPTCHA checkpoints, display the Captcha challenge using the public_key and data provided in the AuthenticationCheckpoint object.

We recommend using the Arkoselabs Client API to render the Captcha and handle the resolution callback.

On resolution, the retrieved code must be forwarded to the Solve a Checkpoint endpoint using the previously created route.


Client code

// components/checkpoints/CaptchaCheckpoint.tsx

import { useEffect } from "react";
import type { SolveCheckpointResponse } from "unipile";
import { solveCheckpoint } from "../../lib/unipile";

export function CaptchaCheckpoint({
  intent_id,
  data,
  public_key,
  onSuccess,
}: {
  intent_id: string;
  data: string;
  public_key: string;
  onSuccess: (data: SolveCheckpointResponse) => void;
}) {
  const handleResolution = async (token: string) => {
    console.log(token);
    const data = await solveCheckpoint({
      intent_id,
      code: token,
    });

    onSuccess(data);
  };

  return (
    <ArkoseLabsCaptcha
      data={data}
      public_key={public_key}
      onSuccess={handleResolution}
      onError={console.error}
    />
  );
}

type Props = {
  data: string;
  public_key: string;
  onSuccess: (token: string) => void;
  onError: (error: Error | null) => void;
};

export function ArkoseLabsCaptcha({
  onError,
  onSuccess,
  data,
  public_key,
}: Props) {
  useEffect(() => {
    const arkoseMessageHandler = (e: MessageEvent<unknown>) => {
      if (typeof e.data === "string") {
        const eventData = JSON.parse(e.data);
        if (isChallengeEvent(eventData)) {
          switch (eventData.eventId) {
            case "challenge-loaded":
              onError(null);
              break;
            case "challenge-complete":
              onSuccess(eventData.payload.sessionToken);
              window.removeEventListener("message", arkoseMessageHandler);
              break;
            case "challenge-error":
            case "challenge-failed":
              onError(new Error("Captcha resolution failed. Please retry."));
              break;
          }
        }
      }
    };
    window.addEventListener("message", arkoseMessageHandler);

    // Inject Arkose Labs enforcement script
    const setupEnforcementScript = document.createElement("script");
    setupEnforcementScript.textContent = makeEnforcementScript({
      targetElementId: "arkose",
      publicKey: public_key,
      data,
      startTime: Date.now(),
    });
    document.head.appendChild(setupEnforcementScript);

    // Load Arkose Labs game script
    const apiScript = document.createElement("script");
    apiScript.src = `//client-api.arkoselabs.com/v2/${public_key}/api.js`;
    apiScript.dataset["callback"] = "setupEnforcement";
    apiScript.defer = true;
    apiScript.async = true;
    document.head.appendChild(apiScript);

    return () => {
      window.removeEventListener("message", arkoseMessageHandler);
      setupEnforcementScript.remove();
      apiScript.remove();
    };
  }, []);

  return <div id="arkose"></div>;
}

type ArkoseChallengeEvent =
  | {
      eventId:
        | "challenge-loaded"
        | "challenge-complete"
        | "challenge-failed"
        | "challenge-shown"
        | "challenge-error";
      publicKey: string;
      payload: {
        sessionToken: string;
      };
    }
  | {
      eventId: "challenge-iframeSize";
      publicKey: string;
      payload: {
        frameHeight: number;
        frameWidth: number;
      };
    };

const isChallengeEvent = (data: unknown): data is ArkoseChallengeEvent => {
  return !!data && typeof data === "object" && "eventId" in data;
};

type MakeEnforcementScriptProps = {
  publicKey: string;
  targetElementId: string;
  data: string;
  startTime: number;
};

const makeEnforcementScript = ({
  data,
  publicKey,
  startTime,
  targetElementId,
}: MakeEnforcementScriptProps) => {
  return `
  function setupEnforcement(e) {
      const endTime = Date.now();

      e.setConfig({
        selector: "#${targetElementId}",
        styleTheme: undefined,
        language: "en",
        data: { blob: "${data}" },
        mode: "inline",
        noSuppress: undefined,
        apiLoadTime: { start: ${startTime}, end: endTime, diff: endTime - ${startTime} },
        onCompleted: function (e) {
          parent.postMessage(
            JSON.stringify({ eventId: "challenge-complete", publicKey: "${publicKey}", payload: { sessionToken: e.token } }),
            "*"
          );
        },
        onReady: function (e) {
          parent.postMessage(
            JSON.stringify({ eventId: "challenge-loaded", publicKey: "${publicKey}", payload: { sessionToken: e.token } }),
            "*"
          );
        },
        onSuppress: function (e) {
          parent.postMessage(
            JSON.stringify({ eventId: "challenge-suppressed", publicKey: "${publicKey}", payload: { sessionToken: e.token } }),
            "*"
          );
        },
        onShown: function (e) {
          parent.postMessage(
            JSON.stringify({ eventId: "challenge-shown", publicKey: "${publicKey}", payload: { sessionToken: e.token } }),
            "*"
          );
        },
        onError: function (e) {
          parent.postMessage(
            JSON.stringify({ eventId: "challenge-error", publicKey: "${publicKey}", payload: { error: e.error } }),
            "*"
          );
        },
        onWarning: function (e) {
          parent.postMessage(
            JSON.stringify({ eventId: "challenge-warning", publicKey: "${publicKey}", payload: { warning: e.warning } }),
            "*"
          );
        },
        onFailed: function (e) {
          parent.postMessage(
            JSON.stringify({ eventId: "challenge-failed", publicKey: "${publicKey}", payload: { sessionToken: e.token } }),
            "*"
          );
        },
        onResize: function (e) {
          var n = e && e.height ? e.height : 450,
            a = e && e.width ? e.width : 400;
          try {
            "string" == typeof n && ((n = n.replace("px", "")), (n = parseInt(n, 10)), isNaN(n) && (n = 450)),
              "string" == typeof a && ((a = a.replace("px", "")), (a = parseInt(a, 10)), isNaN(a) && (a = 400));
          } catch (e) {
            (n = 450), (a = 400);
          }
          parent.postMessage(
            JSON.stringify({ eventId: "challenge-iframeSize", publicKey: "${publicKey}", payload: { frameHeight: n, frameWidth: a } }),
            "*"
          );
        },
      });
  }`;
};

Present action-based checkpoints

Some checkpoints require an action to be made by the user on an external device, like validating an identity on an app sharing the same session.

For that, your application must wait for the validation to be done by making a long request to Solve a Checkpoint which respond when the action is done.



Display waiting screen

Update the screen to handle the action-based IN_APP_VALIDATION type:

  • Indicate that user should open the provider's application to validate it identity
  • Immediately start the request to the previously created route that will wait for the validation by using Solve a Checkpoint.

Client code

// components/checkpoints/ActionCheckpoint.tsx

import { useEffect, useRef } from "react";
import type { SolveCheckpointResponse } from "unipile";
import { solveCheckpoint } from "../../lib/unipile";

export function ActionCheckpoint({
  title,
  intent_id,
  onSuccess,
}: {
  title: string;
  intent_id: string;
  onSuccess: (data: SolveCheckpointResponse) => void;
}) {
  const waiting = useRef(false);

  useEffect(() => {
    if (waiting.current) return;
    waiting.current = true;

    solveCheckpoint({
      intent_id,
      code: "",
    }).then(onSuccess);
  }, []);

  return (
    <div>
      <h1>{title}</h1>
      <p>Waiting for action...</p>
    </div>
  );
}