QR Code Flow

Integrate authentication for provider that requires a QR code to be scanned. A QR code have a short living time and must be refreshed when expired. Read on to implement this flow.

📘

This flow is used by WhatsApp, Google Messages and Telegram


To authenticate users using the QR code flow:

  1. Set up a route to request a first QR Code. This route should call the Start Auth Intent endpoint.
  2. Implement a route to wait for the scan using the Solve a Checkpoint endpoint.
  3. Build a authentication page that display the QR code and wait for scan
  4. Handle new QR codes and refresh display if scan is not fast enough.
  5. Handle timeout and display a retry button.


Request first QR Code



In your local application, implement an authentication page that request a QR Code for the chosen provider on load.

First, create a new route in your application to handle the request of the first QR code. This route should call Start Auth Intent with the provider and credentials.qrcode set to true.

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");
});
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": "google_messages",
  "credentials": {
    "qrcode": true
  }
}
'
# main.py

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)


On the client, call this route on load, then display the qrcode string of the AuthenticationQrCode using a library like qrcode.

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/containers/QrCodeFlowContainer.tsx

import qrcode from "qrcode";
import { useEffect, useRef, useState } from "react";
import type { GetAccountResponse, StartAuthIntentResponse } from "unipile";
import { solveCheckpoint, startAuthIntent } from "../lib/unipile";

export function QrCodeFlowContainer({
  provider,
}: {
  provider: "whatsapp" | "google_messages" | "telegram";
}) {
  const hasMounted = useRef(false);
  const canvasRef = useRef<HTMLCanvasElement>(null);

  const handleSuccess = (response: StartAuthIntentResponse) => {
    if (response.object === "AuthenticationQrCode") {
      // Display the QR Code in a canvas
      qrcode.toCanvas(canvasRef.current, response.qrcode);
    }
  };

  useEffect(() => {
    if (!hasMounted.current) {
      hasMounted.current = true;

      // Request the QR Code on page load
      startAuthIntent({
        provider,
        credentials: { qrcode: true },
      }).then(handleSuccess);
    }
  }, [provider]);

  return (
    <div>
      <h1>{provider} Login</h1>
      <canvas ref={canvasRef} width={276} height={276}></canvas>
    </div>
  );
}

// App.tsx

import { QrCodeFlowContainer } from "./components/containers/QrCodeFlowContainer";

export function App() {
  return <QrCodeFlowContainer provider="whatsapp" />;
}



Wait for scanning

After receiving the QR code, your application should immediately begin waiting for it to be scanned.
This is done by making a long polling request to the Solve a Checkpoint endpoint, using the intent_id provided by the Start Auth Intent call.

The response from Solve a Checkpoint can vary depending on the situation. Let see how to handle each response.


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 AuthenticationQrCode
  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": "",
  "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:3000/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;
}

// components/containers/QrCodeFlowContainer.tsx

//...

const handleSuccess = (response: StartAuthIntentResponse) => {
  if (response.object === "AuthenticationQrCode") {
    // Display the QR Code
    qrcode.toCanvas(canvasRef.current, response.qrcode);

    // Wait for the scan
    solveCheckpoint({ code: "", intent_id: response.intent_id });
  }
};

//...

Handle new codes

If an AuthenticationQrCodeobject is returned, the QR code expired after 60 seconds without being scanned. A new QR code is issued and must be displayed to the user. The previous one is now invalid.

Client code

// components/containers/QrCodeFlowContainer.tsx

//...

const handleSuccess = (response: StartAuthIntentResponse) => {
  if (response.object === "AuthenticationQrCode") {
    // Display the QR Code
    qrcode.toCanvas(canvasRef.current, response.qrcode);

    // Wait for the scan
    solveCheckpoint({ code: "", intent_id: response.intent_id })
      .then(handleSuccess); // Recursively handleSuccess to display new QR Codes
  }
}

//...

Handle scan

If an Accountobject is returned, the user successfully scanned the QR code within 60 seconds. Authentication is complete.

Client code

// components/containers/QrCodeFlowContainer.tsx

//...

export function QrCodeFlowContainer({
  provider,
}: {
  provider: "whatsapp" | "google_messages" | "telegram";
}) {
  const [account, setAccount] = useState<GetAccountResponse>();

  //...

  const handleSuccess = (response: StartAuthIntentResponse) => {

    //...

    if (response.object === "Account") setAccount(response);
  }
  
  //...
  
  // Display a success message if the Account is successfully linked with Unipile
  if (account) {
    return (
      <h1>
        Account {account.name || account.id} is successfully authenticated
      </h1>
    );
  }
}

Handle timeout

If a provider/timeout error is returned, the user failed to scan the QR code in time, even after multiple refreshes. The authentication process must be restarted.
For example, providers like WhatsApp or Google Messages typically rotate QR codes several times before returning a timeout error.

In this case, you can hide the QR code and show a "Retry" button that initiate the same request made on page load.

Client code

// components/containers/QrCodeFlowContainer.tsx

//...

export function QrCodeFlowContainer({
  provider,
}: {
  provider: "whatsapp" | "google_messages" | "telegram";
}) {
  const [shouldRetry, setShouldRetry] = useState<boolean>(false);

  //...

  const handleSuccess = (response: StartAuthIntentResponse) => {

    //...

    // Wait for the scan
    solveCheckpoint({ code: "", intent_id: response.intent_id })
      .then(handleSuccess);
      .catch((error) => {
        if (error.message === "provider/timeout") {
          setShouldRetry(true);
        } else {
          alert(error.message);
        }
      });
  }
  
  //...
  
  const handleRetry = () => {
    setShouldRetry(false);
    startAuthIntent({
      provider,
      credentials: { qrcode: true },
    }).then(handleSuccess);
  };

  // Display a retry button if timeout
  if (shouldRetry) {
    return <button onClick={handleRetry}>Retry</button>;
  }
}

Handle errors

Errors should be handled gracefully :

  • If the user has given a custom proxy, all proxy related errors, such as api/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