Scenario
Every rejected candidate becomes an employer brand risk. What they think about your process — fairly or not — shapes Glassdoor reviews, referral willingness, and market reputation. You pull rejection reasons and stage-at-rejection data from Greenhouse, build personas representing rejected candidates at each stage, then run a Focus Group asking “How does this rejection experience affect your perception of our brand?” The output quantifies the brand cost of your rejection process. Flow: GreenhouseGET /applications (rejected) → Group by rejection reason/stage → Mavera POST /personas → POST /focus-groups → Brand perception impact
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}"}
# 1. Pull rejection reasons
reasons = requests.get(f"{GH_BASE}/rejection_reasons", headers=GH_H).json()
reason_map = {r["id"]: r.get("name", "Unknown") for r in reasons}
# 2. Pull rejected applications
rejected_apps = []
page = 1
while len(rejected_apps) < 500:
batch = requests.get(f"{GH_BASE}/applications",
headers=GH_H,
params={"per_page": 100, "page": page, "status": "rejected"}).json()
if not batch:
break
rejected_apps.extend(batch)
page += 1
time.sleep(0.3)
# 3. Group by rejection stage and reason
stage_groups = defaultdict(list)
for app in rejected_apps:
stage = app.get("current_stage", {})
stage_name = stage.get("name", "Unknown Stage") if stage else "Unknown Stage"
reason_id = app.get("rejection_reason", {})
reason_name = "No reason given"
if reason_id and isinstance(reason_id, dict):
reason_name = reason_id.get("name", reason_map.get(reason_id.get("id"), "Unknown"))
elif reason_id and isinstance(reason_id, int):
reason_name = reason_map.get(reason_id, "Unknown")
stage_groups[stage_name].append({"reason": reason_name, "app_id": app["id"]})
# 4. Create personas per rejection stage
persona_ids = []
stage_summary = []
for stage_name, apps in stage_groups.items():
if len(apps) < 5:
continue
reason_counts = defaultdict(int)
for a in apps:
reason_counts[a["reason"]] += 1
top_reasons = sorted(reason_counts.items(), key=lambda x: -x[1])[:3]
p = requests.post(f"{MV_BASE}/personas", headers=MV_H, json={
"name": f"GH Rejected: {stage_name}",
"description": (
f"Candidate rejected at {stage_name} stage. N={len(apps)}. "
f"Top reasons: {', '.join(f'{r} ({n})' for r, n in top_reasons)}."
),
"psychographic": {
"stage_at_rejection": stage_name,
"emotional_state": "disappointed, evaluating whether to engage with brand again",
},
}).json()
persona_ids.append({"id": p["id"], "stage": stage_name, "n": len(apps)})
stage_summary.append(f"- {stage_name}: {len(apps)} rejected. Top: {top_reasons[0][0]} ({top_reasons[0][1]})")
time.sleep(0.3)
# 5. Focus Group on brand perception
fg = requests.post(f"{MV_BASE}/focus-groups", headers=MV_H, json={
"name": "Rejection Brand Impact Assessment",
"persona_ids": [p["id"] for p in persona_ids],
"questions": [
"How does being rejected at your stage affect your perception of this company as an employer?",
"Would you reapply to this company in 12 months? Would you refer a friend? Why or why not?",
{"type": "ranking", "text": "Rank these factors by how much they affect your post-rejection perception: (A) Speed of response (B) Personalization of rejection (C) Feedback provided (D) Interviewer professionalism (E) Overall process transparency"},
"What would a rejection email need to say to leave you with a positive impression?",
"Would you leave a Glassdoor review about this experience? What would it say?",
],
"context": f"Company rejection data summary:\n" + "\n".join(stage_summary),
"responses_per_persona": 3,
}).json()
# 6. Poll 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
print(f"Focus Group: {fg['id']} | Personas: {len(persona_ids)}")
for resp in data.get("responses", []):
stage = next((p["stage"] for p in persona_ids if p["id"] == resp.get("persona_id")), "?")
print(f"\n[Rejected at: {stage}] {resp.get('question','')[:70]}")
print(f" → {resp.get('answer','')[:300]}")
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}:`)}` };
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(`GH ${res.status}`);
return res.json();
}
// 1. Rejection reasons
const reasons = await ghGet("/rejection_reasons");
const reasonMap = Object.fromEntries(reasons.map((r) => [r.id, r.name || "Unknown"]));
// 2. Rejected applications
const rejectedApps = [];
let page = 1;
while (rejectedApps.length < 500) {
const batch = await ghGet("/applications", { per_page: 100, page, status: "rejected" });
if (!batch.length) break;
rejectedApps.push(...batch);
page++;
await new Promise((r) => setTimeout(r, 300));
}
// 3. Group by stage
const stageGroups = {};
for (const app of rejectedApps) {
const stageName = app.current_stage?.name || "Unknown Stage";
const reasonName = app.rejection_reason?.name || reasonMap[app.rejection_reason?.id] || "No reason";
(stageGroups[stageName] ??= []).push({ reason: reasonName });
}
// 4. Personas
const personaIds = [];
const stageSummary = [];
for (const [stageName, apps] of Object.entries(stageGroups)) {
if (apps.length < 5) continue;
const counts = {};
apps.forEach((a) => { counts[a.reason] = (counts[a.reason] || 0) + 1; });
const topReasons = Object.entries(counts).sort(([, a], [, b]) => b - a).slice(0, 3);
const p = await fetch(`${MV_BASE}/personas`, {
method: "POST", headers: MV_H,
body: JSON.stringify({
name: `GH Rejected: ${stageName}`,
description: `Rejected at ${stageName}. N=${apps.length}. Top: ${topReasons.map(([r, n]) => `${r} (${n})`).join(", ")}.`,
psychographic: { stage_at_rejection: stageName, emotional_state: "disappointed" },
}),
}).then((r) => r.json());
personaIds.push({ id: p.id, stage: stageName, n: apps.length });
stageSummary.push(`- ${stageName}: ${apps.length} rejected. Top: ${topReasons[0][0]} (${topReasons[0][1]})`);
await new Promise((r) => setTimeout(r, 300));
}
// 5. Focus Group
const fg = await fetch(`${MV_BASE}/focus-groups`, {
method: "POST", headers: MV_H,
body: JSON.stringify({
name: "Rejection Brand Impact",
persona_ids: personaIds.map((p) => p.id),
questions: [
"How does rejection at your stage affect your perception of this employer?",
"Would you reapply in 12 months? Refer a friend? Why?",
{ type: "ranking", text: "Rank by perception impact: (A) Response speed (B) Personalization (C) Feedback (D) Interviewer professionalism (E) Transparency" },
"What would a rejection email need to say to leave a positive impression?",
"Would you leave a Glassdoor review? What would it say?",
],
context: `Rejection summary:\n${stageSummary.join("\n")}`,
responses_per_persona: 3,
}),
}).then((r) => r.json());
// 6. 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;
}
console.log(`Focus Group: ${fg.id} | Personas: ${personaIds.length}`);
for (const resp of data.responses || []) {
const stage = personaIds.find((p) => p.id === resp.persona_id)?.stage || "?";
console.log(`\n[Rejected at: ${stage}] ${(resp.question || "").slice(0, 70)}`);
console.log(` → ${(resp.answer || "").slice(0, 300)}`);
}
Example Output
{
"id": "fg_rej_brand_7x2",
"personas": 4,
"stage_breakdown": [
{ "stage": "Application Review", "rejected": 245, "top_reason": "Not qualified (89)" },
{ "stage": "Phone Screen", "rejected": 112, "top_reason": "Unresponsive (34)" },
{ "stage": "Onsite", "rejected": 67, "top_reason": "Culture fit (28)" },
{ "stage": "Offer", "rejected": 12, "top_reason": "Declined offer (8)" }
],
"sample_responses": [
{
"stage": "Onsite",
"question": "Would you reapply?",
"answer": "No. I invested two full days in interviews with no feedback. A form rejection after that level of effort is disrespectful. I'd tell colleagues to skip this company."
},
{
"stage": "Application Review",
"question": "Ranking: perception impact",
"answer": "1. Response speed — waiting 6 weeks for a no is worse than the no itself. 2. Personalization. 3. Transparency. 4. Feedback. 5. Professionalism."
},
{
"stage": "Phone Screen",
"question": "What would a good rejection email say?",
"answer": "Acknowledge the specific role. One sentence of genuine feedback. An invitation to apply for future roles with a direct link. Takes 30 seconds to write."
}
]
}
Error Handling
Rejection reason structure varies
Rejection reason structure varies
The
rejection_reason field can be an object {id, name} or just an ID integer depending on API version. The code handles both formats.Null current_stage
Null current_stage
Applications rejected before entering a stage have
current_stage: null. These are grouped under “Unknown Stage” — typically auto-rejected applications.Large rejection volumes
Large rejection volumes
High-volume orgs may have 10,000+ rejections. Use
created_after parameter to limit to recent data: ?created_after=2025-01-01T00:00:00Z.Privacy considerations
Privacy considerations
Don’t send candidate PII (names, emails) to Mavera. The code only sends aggregate counts, titles, and reasons — never individual identities.