Scenario
Your Greenhouse pipeline has thousands of candidates across sources (LinkedIn, referrals, careers page, agencies) and stages (applied, phone screen, onsite, offer, hired, rejected). Each segment has a distinct experience of your employer brand. You extract candidates grouped by source and stage outcome, build Mavera personas representing each segment, then run Focus Groups to test your employer messaging before publishing it. Flow: GreenhouseGET /candidates → Filter by source/stage → Aggregate traits → Mavera POST /personas → POST /focus-groups → Employer messaging validation
Architecture
Code
import os, requests, time, base64
from collections import defaultdict
GH_KEY = os.environ["GREENHOUSE_API_KEY"]
MV = os.environ["MAVERA_API_KEY"]
GH_BASE = "https://harvest.greenhouse.io/v1"
MV_BASE = "https://app.mavera.io/api/v1"
MV_H = {"Authorization": f"Bearer {MV}", "Content-Type": "application/json"}
gh_auth = base64.b64encode(f"{GH_KEY}:".encode()).decode()
GH_H = {"Authorization": f"Basic {gh_auth}"}
SOURCES = ["LinkedIn", "Referral", "Careers Page"]
OUTCOMES = ["hired", "rejected"]
def gh_get(path, params=None):
r = requests.get(f"{GH_BASE}{path}", headers=GH_H, params=params or {})
if r.status_code == 429:
time.sleep(10)
return gh_get(path, params)
r.raise_for_status()
return r.json()
# 1. Pull candidates and their applications
candidates = []
page = 1
while len(candidates) < 500:
batch = gh_get("/candidates", {"per_page": 100, "page": page})
if not batch:
break
candidates.extend(batch)
page += 1
# 2. Group by source × outcome
segments = defaultdict(list)
for c in candidates:
for app in c.get("applications", []):
source_name = (app.get("source", {}) or {}).get("public_name", "Unknown")
status = app.get("status", "active")
if source_name in SOURCES and status in OUTCOMES:
segments[(source_name, status)].append({
"name": f"{c.get('first_name','')} {c.get('last_name','')}",
"title": c.get("title", ""),
"company": c.get("company", ""),
"application_count": len(c.get("applications", [])),
})
# 3. Create Mavera personas per segment
persona_ids = []
for (source, outcome), members in segments.items():
if len(members) < 3:
continue
titles = list({m["title"] for m in members if m["title"]})[:5]
companies = list({m["company"] for m in members if m["company"]})[:5]
p = requests.post(f"{MV_BASE}/personas", headers=MV_H, json={
"name": f"GH: {source} → {outcome.title()}",
"description": (
f"Candidates from {source} who were {outcome}. "
f"N={len(members)}. Titles: {', '.join(titles[:3])}. "
f"Companies: {', '.join(companies[:3])}."
),
"demographic": {"job_titles": titles},
"psychographic": {
"source": source,
"outcome": outcome,
"mindset": f"Candidate who was {outcome} via {source}",
},
}).json()
persona_ids.append({"id": p["id"], "label": f"{source} → {outcome.title()}"})
print(f"Persona: {p['id']} — {source} → {outcome.title()} ({len(members)} candidates)")
time.sleep(0.3)
# 4. Run Focus Group with employer messaging
EMPLOYER_MESSAGE = """Join a team that ships fast, learns faster, and celebrates wins together.
We offer competitive comp, unlimited PTO, and a culture where engineers own their roadmap.
"Best decision I ever made." — Senior Engineer, 2 years"""
fg = requests.post(f"{MV_BASE}/focus-groups", headers=MV_H, json={
"name": "Employer Brand Messaging Test",
"persona_ids": [p["id"] for p in persona_ids],
"questions": [
"How authentic does this employer message feel on a scale of 1-10?",
"Would this message make you more or less likely to apply? Why?",
"What specific claim feels most credible? Least credible?",
"How would you describe this company's culture to a friend based on this message?",
],
"context": EMPLOYER_MESSAGE,
"responses_per_persona": 3,
}).json()
# 5. Poll for results
for _ in range(20):
time.sleep(5)
data = requests.get(f"{MV_BASE}/focus-groups/{fg['id']}", headers=MV_H).json()
if data.get("status") == "completed":
break
for resp in data.get("responses", []):
label = next((p["label"] for p in persona_ids if p["id"] == resp.get("persona_id")), "?")
print(f"\n[{label}] {resp.get('question','')[:60]}")
print(f" → {resp.get('answer','')[:250]}")
const GH_KEY = process.env.GREENHOUSE_API_KEY;
const MV = process.env.MAVERA_API_KEY;
const GH_BASE = "https://harvest.greenhouse.io/v1";
const MV_BASE = "https://app.mavera.io/api/v1";
const MV_H = { Authorization: `Bearer ${MV}`, "Content-Type": "application/json" };
const GH_H = { Authorization: `Basic ${btoa(`${GH_KEY}:`)}` };
const SOURCES = ["LinkedIn", "Referral", "Careers Page"];
const OUTCOMES = ["hired", "rejected"];
async function ghGet(path, params = {}) {
const qs = new URLSearchParams(params).toString();
const res = await fetch(`${GH_BASE}${path}?${qs}`, { headers: GH_H });
if (res.status === 429) {
await new Promise((r) => setTimeout(r, 10000));
return ghGet(path, params);
}
if (!res.ok) throw new Error(`Greenhouse ${res.status}`);
return res.json();
}
// 1. Pull candidates
const candidates = [];
let page = 1;
while (candidates.length < 500) {
const batch = await ghGet("/candidates", { per_page: 100, page });
if (!batch.length) break;
candidates.push(...batch);
page++;
}
// 2. Group by source × outcome
const segments = {};
for (const c of candidates) {
for (const app of c.applications || []) {
const source = app.source?.public_name || "Unknown";
const status = app.status || "active";
if (SOURCES.includes(source) && OUTCOMES.includes(status)) {
const key = `${source}|${status}`;
(segments[key] ??= []).push({
title: c.title || "",
company: c.company || "",
});
}
}
}
// 3. Create personas
const personaIds = [];
for (const [key, members] of Object.entries(segments)) {
if (members.length < 3) continue;
const [source, outcome] = key.split("|");
const titles = [...new Set(members.map((m) => m.title).filter(Boolean))].slice(0, 5);
const companies = [...new Set(members.map((m) => m.company).filter(Boolean))].slice(0, 5);
const p = await fetch(`${MV_BASE}/personas`, {
method: "POST",
headers: MV_H,
body: JSON.stringify({
name: `GH: ${source} → ${outcome.charAt(0).toUpperCase() + outcome.slice(1)}`,
description: `Candidates from ${source} who were ${outcome}. N=${members.length}. Titles: ${titles.slice(0, 3).join(", ")}.`,
demographic: { job_titles: titles },
psychographic: { source, outcome, mindset: `Candidate who was ${outcome} via ${source}` },
}),
}).then((r) => r.json());
personaIds.push({ id: p.id, label: `${source} → ${outcome}` });
console.log(`Persona: ${p.id} — ${source} → ${outcome} (${members.length})`);
await new Promise((r) => setTimeout(r, 300));
}
// 4. Focus Group
const EMPLOYER_MESSAGE = `Join a team that ships fast, learns faster, and celebrates wins together.
We offer competitive comp, unlimited PTO, and a culture where engineers own their roadmap.
"Best decision I ever made." — Senior Engineer, 2 years`;
const fg = await fetch(`${MV_BASE}/focus-groups`, {
method: "POST",
headers: MV_H,
body: JSON.stringify({
name: "Employer Brand Messaging Test",
persona_ids: personaIds.map((p) => p.id),
questions: [
"How authentic does this employer message feel on a scale of 1-10?",
"Would this message make you more or less likely to apply? Why?",
"What specific claim feels most credible? Least credible?",
"How would you describe this company's culture to a friend?",
],
context: EMPLOYER_MESSAGE,
responses_per_persona: 3,
}),
}).then((r) => r.json());
// 5. Poll
let data;
for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 5000));
data = await fetch(`${MV_BASE}/focus-groups/${fg.id}`, { headers: MV_H }).then((r) => r.json());
if (data.status === "completed") break;
}
for (const resp of data.responses || []) {
const label = personaIds.find((p) => p.id === resp.persona_id)?.label || "?";
console.log(`\n[${label}] ${(resp.question || "").slice(0, 60)}`);
console.log(` → ${(resp.answer || "").slice(0, 250)}`);
}
Example Output
{
"personas_created": 6,
"segments": [
{ "source": "LinkedIn", "outcome": "hired", "n": 82 },
{ "source": "LinkedIn", "outcome": "rejected", "n": 234 },
{ "source": "Referral", "outcome": "hired", "n": 45 },
{ "source": "Referral", "outcome": "rejected", "n": 67 },
{ "source": "Careers Page", "outcome": "hired", "n": 31 },
{ "source": "Careers Page", "outcome": "rejected", "n": 112 }
],
"focus_group_sample": [
{
"persona": "LinkedIn → Rejected",
"question": "How authentic does this employer message feel?",
"answer": "5/10. 'Unlimited PTO' is a red flag — usually means nobody actually takes it. The engineer quote feels planted."
},
{
"persona": "Referral → Hired",
"question": "Would this make you more likely to apply?",
"answer": "More likely. The 'own their roadmap' line matches what my referrer told me. Consistency matters."
}
]
}
Error Handling
Rate limit (50 req/10 sec)
Rate limit (50 req/10 sec)
Greenhouse returns
429 with a Retry-After header. The code sleeps 10 seconds on 429. For bulk pulls (1000+ candidates), add exponential backoff and paginate with per_page=100.Missing source data
Missing source data
Not all applications have a
source object. The code defaults to "Unknown". Check your Greenhouse → Configure → Sources to ensure sources are assigned to all job boards.Application status values
Application status values
Valid statuses:
active, rejected, hired. Custom stages show under current_stage rather than top-level status.Auth encoding
Auth encoding
Greenhouse uses HTTP Basic with the API key as username and an empty password. Always encode as
base64(key + ':') — the trailing colon is required.