Scenario
Some offers get accepted instantly; others languish and decline. The reasons are rarely captured in structured data — they live in recruiter notes and candidate conversations. You pull accepted and declined offers from Lever, build personas for each group, then run a Focus Group asking “What factors drove your decision?” with ranking and open-ended questions. The result tells you what to fix in your offer process. Flow: LeverGET /opportunities (offer stage) → Separate accepted vs declined → Mavera POST /personas → POST /focus-groups (Ranking + open-ended) → Decision factor analysis
Architecture
Code
import os, requests, time, base64
LV_KEY = os.environ["LEVER_API_KEY"]
MV = os.environ["MAVERA_API_KEY"]
LV_BASE = "https://api.lever.co/v1"
MV_BASE = "https://app.mavera.io/api/v1"
MV_H = {"Authorization": f"Bearer {MV}", "Content-Type": "application/json"}
lv_auth = base64.b64encode(f"{LV_KEY}:".encode()).decode()
LV_H = {"Authorization": f"Basic {lv_auth}"}
def lv_get(path, params=None):
r = requests.get(f"{LV_BASE}{path}", headers=LV_H, params=params or {})
if r.status_code == 429:
time.sleep(1)
return lv_get(path, params)
r.raise_for_status()
return r.json()
# 1. Pull archived opportunities
opps = []
offset = None
while len(opps) < 600:
params = {"limit": 100, "archived_posting_id__exists": True}
if offset:
params["offset"] = offset
resp = lv_get("/opportunities", params)
opps.extend(resp.get("data", []))
offset = resp.get("next")
if not offset:
break
time.sleep(0.15)
# 2. Separate accepted (hired) vs declined
stages_resp = lv_get("/stages")
stage_map = {s["id"]: s["text"].lower() for s in stages_resp.get("data", [])}
accepted = []
declined = []
for opp in opps:
stage_name = stage_map.get(opp.get("stage", ""), "")
archive_reason = (opp.get("archivedReason") or "").lower()
if "hired" in stage_name:
accepted.append(opp)
elif "offer" in archive_reason and ("declined" in archive_reason or "rejected" in archive_reason):
declined.append(opp)
elif "offer_declined" in archive_reason or "candidate_rejected" in archive_reason:
declined.append(opp)
# 3. Build persona context
def extract_profile(opps_list):
headlines = list({o.get("headline", "") for o in opps_list if o.get("headline")})[:5]
sources = list({(o.get("sources") or ["Unknown"])[0] for o in opps_list})[:3]
return headlines, sources
acc_headlines, acc_sources = extract_profile(accepted)
dec_headlines, dec_sources = extract_profile(declined)
# 4. Create personas
accepted_persona = requests.post(f"{MV_BASE}/personas", headers=MV_H, json={
"name": "Lever: Offer Accepted",
"description": (
f"Candidates who accepted offers. N={len(accepted)}. "
f"Headlines: {', '.join(acc_headlines[:3])}. Sources: {', '.join(acc_sources)}."
),
"psychographic": {"decision": "accepted", "mindset": "Chose this company over alternatives"},
}).json()
declined_persona = requests.post(f"{MV_BASE}/personas", headers=MV_H, json={
"name": "Lever: Offer Declined",
"description": (
f"Candidates who declined offers. N={len(declined)}. "
f"Headlines: {', '.join(dec_headlines[:3])}. Sources: {', '.join(dec_sources)}."
),
"psychographic": {"decision": "declined", "mindset": "Had a better option or dealbreaker"},
}).json()
time.sleep(0.3)
# 5. Focus Group
fg = requests.post(f"{MV_BASE}/focus-groups", headers=MV_H, json={
"name": "Offer Acceptance Factor Analysis",
"persona_ids": [accepted_persona["id"], declined_persona["id"]],
"questions": [
{"type": "ranking", "text": (
"Rank these factors by importance to your offer decision: "
"(A) Base compensation (B) Equity/stock options (C) Team/manager quality "
"(D) Company mission/product (E) Remote/flexibility policy "
"(F) Career growth opportunity (G) Interview experience "
"(H) Speed of offer process"
)},
"What was the single most important factor in your decision to accept or decline?",
"Was there a moment during the interview process that significantly influenced your final decision?",
"If you declined: What would the company have needed to change about the offer for you to accept?",
"How did the offer compare to your other options? Be specific about the gaps.",
"Describe the ideal offer experience — from verbal offer to signed letter.",
],
"context": f"Company offer data: {len(accepted)} accepted, {len(declined)} declined (acceptance rate: {len(accepted)/max(len(accepted)+len(declined),1)*100:.0f}%)",
"responses_per_persona": 4,
}).json()
# 6. Poll
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"Acceptance rate: {len(accepted)}/{len(accepted)+len(declined)} ({len(accepted)/max(len(accepted)+len(declined),1)*100:.0f}%)")
print(f"Focus Group: {fg['id']}")
for resp in data.get("responses", []):
persona = "Accepted" if resp.get("persona_id") == accepted_persona["id"] else "Declined"
print(f"\n[{persona}] {resp.get('question','')[:70]}")
print(f" → {resp.get('answer','')[:300]}")
const LV_KEY = process.env.LEVER_API_KEY;
const MV = process.env.MAVERA_API_KEY;
const LV_BASE = "https://api.lever.co/v1";
const MV_BASE = "https://app.mavera.io/api/v1";
const MV_H = { Authorization: `Bearer ${MV}`, "Content-Type": "application/json" };
const LV_H = { Authorization: `Basic ${btoa(`${LV_KEY}:`)}` };
async function lvGet(path, params = {}) {
const qs = new URLSearchParams(params).toString();
const res = await fetch(`${LV_BASE}${path}?${qs}`, { headers: LV_H });
if (res.status === 429) { await new Promise((r) => setTimeout(r, 1000)); return lvGet(path, params); }
if (!res.ok) throw new Error(`Lever ${res.status}`);
return res.json();
}
// 1. Archived opportunities
const opps = [];
let offset = null;
while (opps.length < 600) {
const params = { limit: 100, archived_posting_id__exists: true };
if (offset) params.offset = offset;
const resp = await lvGet("/opportunities", params);
opps.push(...(resp.data || []));
offset = resp.next;
if (!offset) break;
await new Promise((r) => setTimeout(r, 150));
}
// 2. Separate
const stageMap = Object.fromEntries(
((await lvGet("/stages")).data || []).map((s) => [s.id, s.text.toLowerCase()])
);
const accepted = [], declined = [];
for (const opp of opps) {
const stage = stageMap[opp.stage] || "";
const reason = (opp.archivedReason || "").toLowerCase();
if (stage.includes("hired")) accepted.push(opp);
else if (reason.includes("declined") || reason.includes("rejected")) declined.push(opp);
}
// 3. Personas
const extract = (list) => ({
headlines: [...new Set(list.map((o) => o.headline).filter(Boolean))].slice(0, 5),
sources: [...new Set(list.map((o) => (o.sources || ["Unknown"])[0]))].slice(0, 3),
});
const accProfile = extract(accepted);
const decProfile = extract(declined);
const accPersona = await fetch(`${MV_BASE}/personas`, {
method: "POST", headers: MV_H,
body: JSON.stringify({
name: "Lever: Offer Accepted",
description: `Accepted offers. N=${accepted.length}. Headlines: ${accProfile.headlines.slice(0, 3).join(", ")}.`,
psychographic: { decision: "accepted" },
}),
}).then((r) => r.json());
const decPersona = await fetch(`${MV_BASE}/personas`, {
method: "POST", headers: MV_H,
body: JSON.stringify({
name: "Lever: Offer Declined",
description: `Declined offers. N=${declined.length}. Headlines: ${decProfile.headlines.slice(0, 3).join(", ")}.`,
psychographic: { decision: "declined" },
}),
}).then((r) => r.json());
// 4. Focus Group
const fg = await fetch(`${MV_BASE}/focus-groups`, {
method: "POST", headers: MV_H,
body: JSON.stringify({
name: "Offer Acceptance Factors",
persona_ids: [accPersona.id, decPersona.id],
questions: [
{ type: "ranking", text: "Rank by importance: (A) Base comp (B) Equity (C) Team/manager (D) Mission (E) Flexibility (F) Growth (G) Interview experience (H) Offer speed" },
"What was the single most important factor?",
"Was there a pivotal moment during interviews?",
"If declined: What change to the offer would have flipped your decision?",
"How did this offer compare to alternatives? Be specific.",
"Describe the ideal offer experience — verbal to signed.",
],
context: `${accepted.length} accepted, ${declined.length} declined (${(accepted.length / Math.max(accepted.length + declined.length, 1) * 100).toFixed(0)}% rate)`,
responses_per_persona: 4,
}),
}).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;
}
const rate = (accepted.length / Math.max(accepted.length + declined.length, 1) * 100).toFixed(0);
console.log(`Acceptance rate: ${accepted.length}/${accepted.length + declined.length} (${rate}%)`);
for (const resp of data.responses || []) {
const label = resp.persona_id === accPersona.id ? "Accepted" : "Declined";
console.log(`\n[${label}] ${(resp.question || "").slice(0, 70)}`);
console.log(` → ${(resp.answer || "").slice(0, 300)}`);
}
Example Output
{
"acceptance_rate": "68%",
"accepted": 45,
"declined": 21,
"ranking_consensus": {
"accepted": ["C (Team/manager)", "F (Growth)", "A (Base comp)", "D (Mission)", "E (Flexibility)"],
"declined": ["A (Base comp)", "B (Equity)", "E (Flexibility)", "H (Offer speed)", "C (Team/manager)"]
},
"key_insights": [
{
"persona": "Accepted",
"insight": "Team quality is the #1 factor. 'I met my future manager during the onsite — she was brilliant and clearly cared about mentorship. That sealed it.'"
},
{
"persona": "Declined",
"insight": "Comp was #1 but speed was #4. 'Your offer took 2 weeks after the final round. By then I had a signed offer from [competitor]. If you'd moved in 48hrs, I might have chosen differently.'"
},
{
"persona": "Declined",
"insight": "'The equity explanation was confusing. I couldn't compare your options to the RSUs I had from [competitor]. A clear comparison doc would have helped.'"
}
]
}
Error Handling
Archive reason formats
Archive reason formats
Lever’s
archivedReason is a free-text string that varies by org. Common values: "Offer declined", "Candidate rejected offer", "Withdrew". Normalize with case-insensitive matching.No explicit offer object
No explicit offer object
Lever doesn’t have a standalone Offers endpoint in v1. Offer data lives on the Opportunity as stage + archive reason. Use
GET /opportunities/{id}/offers for detailed offer fields if available.Small sample sizes
Small sample sizes
If fewer than 10 declined offers, Focus Group results are directional but not statistically meaningful. Note this in your report.