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
- 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.
- The agency approves. Lovelio sends them to your callback with a one-time code.
- 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.
- 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.