Lovelio Connect

The connect flow, in code

Agencies click Connect on your listing and approve, without leaving Lovelio. Your side is two small routes on your own server. The portal gives you one prompt that a coding agent turns into both; this page is the same flow written out for a person. Replace the placeholders with the values from the Keys and callback panel of your integration in the portal.

How it works

  1. Lovelio sends the agency to your connect address. Your connect route redirects them to Lovelio's authorize endpoint with your client ID, your callback and the scopes you were approved for.
  2. The agency approves. Lovelio sends them to your callback with a one-time code.
  3. Your callback route swaps the code for tokens, server to server, using your client secret. Store the refresh token; access tokens are short-lived and refresh tokens rotate on every use.
  4. Send the agency back into Lovelio. The whole thing reads as two clicks inside the app they were already in.

Your callback must match a registered callback exactly, including the path, port and trailing slash. Read the granted scope from the token response rather than assuming it; an agency can approve less than you asked for.

Test it

Open your sandbox from the portal, go to Settings > Connected apps and click Connect on your app. That runs the flow above against your code. Each step ticks itself in the portal the first time it happens.

Environment

The four values every sample reads. The portal prints this block with your real values when it issues the secret.

.env

LOVELIO_API_URL=https://us.lovelio.ai
LOVELIO_CLIENT_ID=lc_client_YOUR_CLIENT_ID
LOVELIO_CLIENT_SECRET=<rotate in the portal to see a secret>
LOVELIO_REDIRECT_URI=https://yourapp.com/lovelio/callback

Node

connect.js

// Step 1 of 2. Mount this and send agencies to /lovelio/connect.
import crypto from 'node:crypto'
import { Router } from 'express'

// Your own values, already filled in. The env vars win when they are set, so
// this runs before you have written a .env and stays right after you have.
const API_URL = process.env.LOVELIO_API_URL || 'https://us.lovelio.ai'
const CLIENT_ID = process.env.LOVELIO_CLIENT_ID || 'lc_client_YOUR_CLIENT_ID'
const REDIRECT_URI = process.env.LOVELIO_REDIRECT_URI || 'https://yourapp.com/lovelio/callback'
const SCOPES = 'jobs:read candidates:read'

export const router = Router()

router.get('/lovelio/connect', (req, res) => {
  // PKCE: a random string (the verifier) never leaves your server. Only its
  // SHA-256 hash (the challenge) travels to Lovelio. Sending the verifier at the
  // token step proves the code came back to the server that started the flow.
  const verifier = crypto.randomBytes(32).toString('base64url')
  const challenge = crypto.createHash('sha256').update(verifier).digest('base64url')
  const state = crypto.randomBytes(16).toString('base64url')

  // Both have to survive until the agency comes back. Any per-browser store
  // does: a session, a signed cookie, a row keyed by state.
  req.session.lovelio = { verifier, state }

  const url = new URL('/api/oauth/authorize', API_URL)
  url.searchParams.set('response_type', 'code')
  url.searchParams.set('client_id', CLIENT_ID)
  url.searchParams.set('redirect_uri', REDIRECT_URI)
  url.searchParams.set('scope', SCOPES)
  url.searchParams.set('state', state)
  url.searchParams.set('code_challenge', challenge)
  url.searchParams.set('code_challenge_method', 'S256')

  res.redirect(url.toString())
})

callback.js

// Step 2 of 2. This file serves https://yourapp.com/lovelio/callback.
import { Router } from 'express'

const API_URL = process.env.LOVELIO_API_URL || 'https://us.lovelio.ai'
const CLIENT_ID = process.env.LOVELIO_CLIENT_ID || 'lc_client_YOUR_CLIENT_ID'
const REDIRECT_URI = process.env.LOVELIO_REDIRECT_URI || 'https://yourapp.com/lovelio/callback'
// No fallback on purpose: a secret in source is a secret in your git history.
const CLIENT_SECRET = process.env.LOVELIO_CLIENT_SECRET

export const router = Router()

router.get('/lovelio/callback', async (req, res) => {
  const saved = req.session.lovelio
  if (req.query.error) {
    return res.status(400).send(req.query.error_description || req.query.error)
  }
  // The agency cancelled, or someone else sent this request.
  if (!saved || req.query.state !== saved.state) {
    return res.status(400).send('State did not match. Start again from Lovelio.')
  }

  const body = new URLSearchParams({
    grant_type: 'authorization_code',
    code: String(req.query.code),
    redirect_uri: REDIRECT_URI,
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
    code_verifier: saved.verifier,
  })

  const response = await fetch(new URL('/api/oauth/token', API_URL), {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body,
  })
  const token = await response.json()
  if (!response.ok) {
    return res.status(502).send(token.error_description || 'Token exchange failed')
  }

  // token.access_token   lc_at_...  3600 seconds
  // token.refresh_token  lc_rt_...  rotates on every use, store the new one
  // token.scope          exactly what this agency granted, which can be less than you asked for
  await saveConnection(token)

  delete req.session.lovelio
  res.redirect('/settings/lovelio?connected=1')
})

// Your storage. One row per agency: the tokens, the expiry, the granted scope.
async function saveConnection(token) {
  throw new Error('Store the tokens against the agency, then delete this line.')
}

// Calling the API with what you just got:
//   fetch(new URL('/api/v1/jobs', API_URL), {
//     headers: { Authorization: 'Bearer ' + token.access_token },
//   })

Python

connect.py

# Step 1 of 2. Send agencies to /lovelio/connect.
import base64
import hashlib
import os
import secrets
from urllib.parse import urlencode

from flask import Blueprint, redirect, session

# Your own values, already filled in. The env vars win when they are set.
API_URL = os.environ.get("LOVELIO_API_URL", "https://us.lovelio.ai")
CLIENT_ID = os.environ.get("LOVELIO_CLIENT_ID", "lc_client_YOUR_CLIENT_ID")
REDIRECT_URI = os.environ.get("LOVELIO_REDIRECT_URI", "https://yourapp.com/lovelio/callback")
SCOPES = "jobs:read candidates:read"

connect = Blueprint("lovelio_connect", __name__)


def _b64url(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).decode().rstrip("=")


@connect.get("/lovelio/connect")
def start_connect():
    # PKCE: a random string (the verifier) never leaves your server. Only its
    # SHA-256 hash (the challenge) travels to Lovelio. Sending the verifier at the
    # token step proves the code came back to the server that started the flow.
    verifier = _b64url(secrets.token_bytes(32))
    challenge = _b64url(hashlib.sha256(verifier.encode()).digest())
    state = _b64url(secrets.token_bytes(16))

    # Both have to survive until the agency comes back. Any per-browser store
    # does: the session, a signed cookie, a row keyed by state.
    session["lovelio"] = {"verifier": verifier, "state": state}

    query = urlencode(
        {
            "response_type": "code",
            "client_id": CLIENT_ID,
            "redirect_uri": REDIRECT_URI,
            "scope": SCOPES,
            "state": state,
            "code_challenge": challenge,
            "code_challenge_method": "S256",
        }
    )
    return redirect(API_URL + "/api/oauth/authorize?" + query)

callback.py

# Step 2 of 2. This file serves https://yourapp.com/lovelio/callback.
import os

import requests
from flask import Blueprint, redirect, request, session

API_URL = os.environ.get("LOVELIO_API_URL", "https://us.lovelio.ai")
CLIENT_ID = os.environ.get("LOVELIO_CLIENT_ID", "lc_client_YOUR_CLIENT_ID")
REDIRECT_URI = os.environ.get("LOVELIO_REDIRECT_URI", "https://yourapp.com/lovelio/callback")
# No fallback on purpose: a secret in source is a secret in your git history.
CLIENT_SECRET = os.environ["LOVELIO_CLIENT_SECRET"]

callback = Blueprint("lovelio_callback", __name__)


@callback.get("/lovelio/callback")
def finish_connect():
    if request.args.get("error"):
        return request.args.get("error_description", request.args["error"]), 400

    saved = session.pop("lovelio", None)
    # The agency cancelled, or someone else sent this request.
    if not saved or request.args.get("state") != saved["state"]:
        return "State did not match. Start again from Lovelio.", 400

    response = requests.post(
        API_URL + "/api/oauth/token",
        data={
            "grant_type": "authorization_code",
            "code": request.args["code"],
            "redirect_uri": REDIRECT_URI,
            "client_id": CLIENT_ID,
            "client_secret": CLIENT_SECRET,
            "code_verifier": saved["verifier"],
        },
        timeout=15,
    )
    token = response.json()
    if not response.ok:
        return token.get("error_description", "Token exchange failed"), 502

    # token["access_token"]   lc_at_...  3600 seconds
    # token["refresh_token"]  lc_rt_...  rotates on every use, store the new one
    # token["scope"]          exactly what this agency granted, which can be less than you asked for
    save_connection(token)

    return redirect("/settings/lovelio?connected=1")


def save_connection(token):
    """Your storage. One row per agency: tokens, expiry, granted scope."""
    raise NotImplementedError("Store the tokens against the agency, then delete this line.")


# Calling the API with what you just got:
#   requests.get(
#       API_URL + "/api/v1/jobs",
#       headers={"Authorization": "Bearer " + token["access_token"]},
#   )

PHP

connect.php

<?php
// Step 1 of 2. Send agencies to this file.
session_start();

// Your own values, already filled in. The env vars win when they are set.
$apiUrl = getenv('LOVELIO_API_URL') ?: 'https://us.lovelio.ai';
$clientId = getenv('LOVELIO_CLIENT_ID') ?: 'lc_client_YOUR_CLIENT_ID';
$redirectUri = getenv('LOVELIO_REDIRECT_URI') ?: 'https://yourapp.com/lovelio/callback';
$scopes = 'jobs:read candidates:read';

function b64url(string $raw): string {
    return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
}

// PKCE: a random string (the verifier) never leaves your server. Only its
// SHA-256 hash (the challenge) travels to Lovelio. Sending the verifier at the
// token step proves the code came back to the server that started the flow.
$verifier = b64url(random_bytes(32));
$challenge = b64url(hash('sha256', $verifier, true));
$state = b64url(random_bytes(16));

// Both have to survive until the agency comes back.
$_SESSION['lovelio'] = ['verifier' => $verifier, 'state' => $state];

$query = http_build_query([
    'response_type' => 'code',
    'client_id' => $clientId,
    'redirect_uri' => $redirectUri,
    'scope' => $scopes,
    'state' => $state,
    'code_challenge' => $challenge,
    'code_challenge_method' => 'S256',
]);

header('Location: ' . $apiUrl . '/api/oauth/authorize?' . $query);
exit;

callback.php

<?php
// Step 2 of 2. This file serves https://yourapp.com/lovelio/callback.
session_start();

$apiUrl = getenv('LOVELIO_API_URL') ?: 'https://us.lovelio.ai';
$clientId = getenv('LOVELIO_CLIENT_ID') ?: 'lc_client_YOUR_CLIENT_ID';
$redirectUri = getenv('LOVELIO_REDIRECT_URI') ?: 'https://yourapp.com/lovelio/callback';
// No fallback on purpose: a secret in source is a secret in your git history.
$clientSecret = getenv('LOVELIO_CLIENT_SECRET');

if (isset($_GET['error'])) {
    http_response_code(400);
    exit($_GET['error_description'] ?? $_GET['error']);
}

$saved = $_SESSION['lovelio'] ?? null;
unset($_SESSION['lovelio']);

// The agency cancelled, or someone else sent this request.
if (!$saved || ($_GET['state'] ?? '') !== $saved['state']) {
    http_response_code(400);
    exit('State did not match. Start again from Lovelio.');
}

$ch = curl_init($apiUrl . '/api/oauth/token');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POSTFIELDS => http_build_query([
        'grant_type' => 'authorization_code',
        'code' => $_GET['code'],
        'redirect_uri' => $redirectUri,
        'client_id' => $clientId,
        'client_secret' => $clientSecret,
        'code_verifier' => $saved['verifier'],
    ]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$token = json_decode($body, true);
if ($status >= 400) {
    http_response_code(502);
    exit($token['error_description'] ?? 'Token exchange failed');
}

// $token['access_token']   lc_at_...  3600 seconds
// $token['refresh_token']  lc_rt_...  rotates on every use, store the new one
// $token['scope']          exactly what this agency granted, which can be less than you asked for
save_connection($token);

header('Location: /settings/lovelio?connected=1');
exit;

// Your storage. One row per agency: tokens, expiry, granted scope.
function save_connection(array $token): void {
    throw new RuntimeException('Store the tokens against the agency, then delete this line.');
}

// Calling the API with what you just got:
//   Authorization: Bearer <access_token>  against  <api url>/api/v1/jobs

Go

connect.go

// Step 1 of 2. Send agencies to /lovelio/connect.
package lovelio

import (
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"net/http"
	"net/url"
	"os"
)

// Your own values, already filled in. The env vars win when they are set.
var (
	apiURL      = env("LOVELIO_API_URL", "https://us.lovelio.ai")
	clientID    = env("LOVELIO_CLIENT_ID", "lc_client_YOUR_CLIENT_ID")
	redirectURI = env("LOVELIO_REDIRECT_URI", "https://yourapp.com/lovelio/callback")
	scopes      = "jobs:read candidates:read"
)

func env(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}

func b64url(n int) string {
	raw := make([]byte, n)
	rand.Read(raw)
	return base64.RawURLEncoding.EncodeToString(raw)
}

func Connect(w http.ResponseWriter, r *http.Request) {
	// PKCE: a random string (the verifier) never leaves your server. Only its
	// SHA-256 hash (the challenge) travels to Lovelio. Sending the verifier at the
	// token step proves the code came back to the server that started the flow.
	verifier := b64url(32)
	sum := sha256.Sum256([]byte(verifier))
	challenge := base64.RawURLEncoding.EncodeToString(sum[:])
	state := b64url(16)

	// Both have to survive until the agency comes back. Swap this for your own
	// session store; a row keyed by state works just as well.
	saveSession(w, state, verifier)

	q := url.Values{}
	q.Set("response_type", "code")
	q.Set("client_id", clientID)
	q.Set("redirect_uri", redirectURI)
	q.Set("scope", scopes)
	q.Set("state", state)
	q.Set("code_challenge", challenge)
	q.Set("code_challenge_method", "S256")

	http.Redirect(w, r, apiURL+"/api/oauth/authorize?"+q.Encode(), http.StatusFound)
}

callback.go

// Step 2 of 2. This file serves https://yourapp.com/lovelio/callback.
package lovelio

import (
	"encoding/json"
	"net/http"
	"net/url"
	"os"
	"strings"
)

// No fallback on purpose: a secret in source is a secret in your git history.
var clientSecret = os.Getenv("LOVELIO_CLIENT_SECRET")

type Token struct {
	AccessToken  string `json:"access_token"`
	RefreshToken string `json:"refresh_token"`
	TokenType    string `json:"token_type"`
	ExpiresIn    int    `json:"expires_in"`
	Scope        string `json:"scope"`
	ErrorDesc    string `json:"error_description"`
}

func Callback(w http.ResponseWriter, r *http.Request) {
	if e := r.URL.Query().Get("error"); e != "" {
		http.Error(w, r.URL.Query().Get("error_description"), http.StatusBadRequest)
		return
	}

	state := r.URL.Query().Get("state")
	verifier, ok := loadSession(r, state)
	// The agency cancelled, or someone else sent this request.
	if !ok {
		http.Error(w, "State did not match. Start again from Lovelio.", http.StatusBadRequest)
		return
	}

	form := url.Values{}
	form.Set("grant_type", "authorization_code")
	form.Set("code", r.URL.Query().Get("code"))
	form.Set("redirect_uri", redirectURI)
	form.Set("client_id", clientID)
	form.Set("client_secret", clientSecret)
	form.Set("code_verifier", verifier)

	res, err := http.Post(
		apiURL+"/api/oauth/token",
		"application/x-www-form-urlencoded",
		strings.NewReader(form.Encode()),
	)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadGateway)
		return
	}
	defer res.Body.Close()

	var token Token
	json.NewDecoder(res.Body).Decode(&token)
	if res.StatusCode >= 400 {
		http.Error(w, token.ErrorDesc, http.StatusBadGateway)
		return
	}

	// token.AccessToken   lc_at_...  3600 seconds
	// token.RefreshToken  lc_rt_...  rotates on every use, store the new one
	// token.Scope         exactly what this agency granted, which can be less than you asked for
	saveConnection(token)

	http.Redirect(w, r, "/settings/lovelio?connected=1", http.StatusFound)
}

// Calling the API with what you just got:
//   req.Header.Set("Authorization", "Bearer "+token.AccessToken)
//   against apiURL + "/api/v1/jobs"

cURL

connect.sh

#!/usr/bin/env bash
# The whole connect flow by hand, to see each request on its own. Run it, open the
# URL it prints, approve as an agency admin, then paste the code back.
set -euo pipefail

# Your own values, already filled in.
API_URL="${LOVELIO_API_URL:-https://us.lovelio.ai}"
CLIENT_ID="${LOVELIO_CLIENT_ID:-lc_client_YOUR_CLIENT_ID}"
REDIRECT_URI="${LOVELIO_REDIRECT_URI:-https://yourapp.com/lovelio/callback}"
SCOPES="jobs:read candidates:read"

# The secret is read from the environment, never written here.
: "${LOVELIO_CLIENT_SECRET:?set LOVELIO_CLIENT_SECRET first}"

b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }

# PKCE: a random string (the verifier) never leaves your server. Only its
# SHA-256 hash (the challenge) travels to Lovelio. Sending the verifier at the
# token step proves the code came back to the server that started the flow.
VERIFIER=$(openssl rand 32 | b64url)
CHALLENGE=$(printf %s "$VERIFIER" | openssl dgst -binary -sha256 | b64url)
STATE=$(openssl rand 16 | b64url)

echo "Open this as an agency admin and approve:"
echo
echo "$API_URL/api/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$REDIRECT_URI&scope=$(printf %s "$SCOPES" | tr ' ' '+')&state=$STATE&code_challenge=$CHALLENGE&code_challenge_method=S256"
echo
read -r -p "Paste the whole URL you landed on: " LANDED

CODE=$(printf %s "$LANDED" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p')
RETURNED_STATE=$(printf %s "$LANDED" | sed -n 's/.*[?&]state=\([^&]*\).*/\1/p')

# Your callback does this check every time: a code arriving with a state
# you did not issue is not your connection.
if [ "$RETURNED_STATE" != "$STATE" ]; then
  echo "State did not match. Start again from Lovelio." >&2
  exit 1
fi

# Exchanging the code. code_verifier is the string the challenge was made from,
# which is why the two steps have to happen in one run.
curl -sS -X POST "$API_URL/api/oauth/token" \
  -d grant_type=authorization_code \
  -d code="$CODE" \
  -d redirect_uri="$REDIRECT_URI" \
  -d client_id="$CLIENT_ID" \
  -d client_secret="$LOVELIO_CLIENT_SECRET" \
  -d code_verifier="$VERIFIER"

# You get back access_token (lc_at_..., 3600 seconds),
# refresh_token (lc_rt_..., rotates on every use) and the granted scope.
# Then:  curl "$API_URL/api/v1/jobs" -H "Authorization: Bearer lc_at_..."

Building with an agent after all? Open your integration in the portal and copy the setup prompt; it carries your own values. The rest of the platform is in the integration guide.