Announcing CIMD support for MCP Client registration
Learn more

MCP vs API for Integrating Lever to AI Agents

Kuntal Banerjee
Founding Engineer

Lever has announced an official MCP connector that is read-only and not yet shipped, community MCP servers run on a single API key, and the Lever API v1 is the first-party surface you can build on today. Behind the API sits a partner-gated OAuth flow whose scopes are all account-wide. This guide compares what each option lets a recruiting copilot do, where per-user boundaries have to come from, and how to run the API path across many customer companies in production.

TL;DR

  • Lever has announced an official Lever MCP Connector for Claude, Copilot, ChatGPT, and Slack, but its product page still says "Coming Soon," publishes no endpoint or tool list, and states that "access is read-only and scoped to each person's existing Lever permissions." Setup goes through your Customer Account Manager. Today's working Lever MCP servers are community projects, such as the-sid-dani/lever-mcp-integration (15 tools) and napiergit/lever-mcp (3 Lever tools), that read one LEVER_API_KEY from the environment.
  • The Lever API v1 at https://api.lever.co/v1 is the only first-party surface you can build on now. It takes Basic auth with an API key for internal workflows and OAuth 2.0 for integrations, and OAuth is only available through Lever's partner program. Access tokens last one hour, refresh tokens rotate and expire after one year or 90 days of inactivity, and every data scope Lever publishes ends in :admin.
  • That :admin suffix is the Lever-specific catch. An OAuth grant reaches the whole Lever account, not one recruiter's slice of it. The official MCP Connector will enforce each person's permissions; the API path leaves that line for you to draw, and perform_as only decides who a write is attributed to, not what it may touch.
  • A community MCP server is fine for one recruiting ops lead querying their own company's Lever from Claude. The API is the path for a product that serves many customer companies: per-account OAuth, candidateStageChange webhooks, stage moves, archiving with hire reasons, interview panels, and agents that run without a chat window open.
  • Scalekit's lever connector exposes 77 prebuilt tools over the Lever API (46 read, 25 write, 6 destructive) with OAuth 2.0 through your own Lever app, and stores each account's tokens in the token vault, so the MCP vs API decision doesn't change your auth infrastructure.

The decision your Lever agent forces first

You are building a recruiting copilot inside a B2B HR product, and 80 customer companies run their hiring in Lever. The copilot needs to tell a hiring manager which of their candidates have stalled, nudge interviewers who never submitted feedback, log a note on a candidate after a phone screen, and move someone to Onsite when the recruiter approves. You go looking for a Lever MCP server. Lever has announced one, read-only and not yet shipped. Registries list a handful of community servers that each wrap one API key. And there is the Lever API v1, with OAuth gated behind a partner program and scopes that all end in :admin. Those three options differ on writes, on whose permissions apply, and on who runs the server. Here's how to pick.

What Lever MCP and Lever API actually are

Lever MCP

Lever's own entry is the Lever MCP Connector. Its page on lever.co describes "a secure server" that "securely connects your ATS to Claude, Microsoft Copilot, ChatGPT, and Slack," and works with "any MCP-compatible AI client." The use cases are self-serve: hiring managers checking pipeline status without pinging a recruiter, and interviewers reading candidate background before they walk in. Three facts matter for builders. Access is read-only. It is scoped to each person's existing Lever permissions, with "no separate login." And as of November 2026 the page carries a "Coming Soon" label, with setup routed through "your Customer Account Manager." Lever publishes no endpoint URL, tool list, or auth documentation for it yet.

What runs today is community and vendor work:

  • the-sid-dani/lever-mcp-integration is a local Python server for Claude Desktop with 15 tools, including lever_search_candidates, lever_add_note, lever_archive_candidate, lever_list_open_roles, lever_find_internal_referrals_for_role, and lever_create_application. Glama lists it as inactive.
  • napiergit/lever-mcp has 7 tools, of which three touch Lever (list_candidates, get_candidate, create_requisition) and four handle Gmail. It supports stdio and Streamable HTTP, and points at the sandbox when you set LEVER_API_BASE_URL.
  • Hosted vendor servers from Ribbon, Composio, Knit, Apideck, and StackOne expose Lever over MCP. Ribbon's, for example, reads Lever "using a key that a Lever admin generates." These are those vendors' servers over Lever's API, not Lever's.

Every community server we reviewed authenticates the same way: a Lever API key in LEVER_API_KEY. None runs OAuth.

Lever API

The Lever API is REST at https://api.lever.co/v1, HTTPS only. Its model is relational. A contact is one person; an opportunity is one candidacy of that contact, and each opportunity carries at most one application. The older /candidates routes are deprecated in favor of /opportunities. Around that core sit stages, archive reasons, tags, sources, notes, feedback forms and templates, profile forms, interviews and panels, offers, referrals, resumes, files, postings, requisitions and requisition fields, users, roles, audit events, and webhooks. Lists return data, next, and hasNext; you pass next back as the opaque offset and cap limit at 100.

Auth takes two forms:

  • API key: Basic auth with the key as the username and a blank password. Lever positions keys for internal workflows and warns they "carry many privileges, so keep them secret." Access to confidential postings, opportunities, and requisitions "may only be granted during key creation."
  • OAuth 2.0: Authorization Code against https://auth.lever.co/authorize with audience=https://api.lever.co/v1/, then a token exchange at https://auth.lever.co/oauth/token. Lever states that "OAuth is part of our partner integration program, and apps must be registered."

A sandbox mirrors production at https://api.sandbox.lever.co/v1, with its own OAuth app and authorize URL at https://sandbox-lever.auth0.com/authorize, per Lever's OAuth guide. There is also a public Postings API for job sites that needs no auth and has nothing to do with candidate data.

Comparing them where it matters for agents

What your agent can actually do

Capability
Lever MCP (official connector; community servers)
Lever API v1
Vendor-maintained, available today
Limited: Official connector is "Coming Soon" with no published endpoint; community servers are unofficial
Yes: Lever-owned REST contract
Read pipeline: opportunities, stages, postings
Yes: Official connector is read-only by design; community servers search candidates and roles
Yes
Respect each user's Lever permissions
Limited: Official connector, per Lever; community servers run as one API key
Limited: Scopes are account-wide; you enforce, helped by the posting users list
Interview feedback and scorecards
Limited: Not documented for either
Yes
Move a candidate between stages
No: Read-only official; not in the community tool lists
Yes
Reject or hire (archive with a reason)
Limited
Yes: Archive with a hired reason and requisition
Notes with @mentions
Limited
Yes
Schedule interviews
No
Yes: On externally managed panels
Offers
No
Limited
Postings and requisitions
Limited
Yes
Deletion sync
No
Yes
React to stage changes and hires
No: Tools only run inside a chat
Yes: Stage change and hired webhooks; not a connector tool

Lever's official connector is built for a different job than the one most agent builders have. It answers questions for the person asking, within what they can already see, and it never writes. That is exactly right for a hiring manager in Copilot. It cannot chase a scorecard, move a candidate, or react when a stage changes, and it is not shipped.

The community servers cover more writes than the official one, but on a single key. A recruiter's archive and a hiring manager's question run with the same reach, whatever that key was granted.

What the MCP path makes easier

"Which of my candidates for the Senior Backend role have been sitting in Onsite for more than a week, and who still owes feedback" on the raw API is a walk across five resources. You resolve the hiring manager's user ID with GET /users, find postings where they are hiringManager, map the stage name to a stage UID, list opportunities filtered by posting_id and stage_id with an advanced_at_end cutoff, then call interviews and feedback per opportunity and compare interviewers against submitted forms. Timestamps are Unix milliseconds throughout. Every page lands in the model's context.

An MCP server folds that into a conversation. With the official connector, the hiring manager connects with no extra login, and Lever decides what they can see. With a community server, the model picks tools by name and the server handles the paging. For a single recruiting team, that is a working assistant in an afternoon. Writing the schema is the hard part. Not the API call.

The tradeoff is ownership. Lever's connector will change on Lever's schedule, with no published changelog yet. A community server changes when its maintainer pushes, and one of the two we looked at is flagged inactive. An agent tuned against either inherits that.

The auth path each one puts you on

On the MCP path, the official connector promises per-user access with "no separate login," which suggests it rides each person's existing Lever session, but Lever has not documented the mechanism. The community servers use an API key that an admin creates in the customer's Lever account settings. The key has no documented expiry, carries whatever endpoint permissions and confidential access were chosen when it was made, and sits in plaintext in claude_desktop_config.json or a .env file on someone's laptop. When that person leaves, the key keeps working until someone revokes it.

On the direct API path, you pick between that API key and an OAuth app. The key is right for an internal script against your own company's Lever. For a product serving customer companies, Lever expects OAuth, and OAuth means the partner process. Lever creates your OAuth app and client credentials, you build against a sandbox account, Lever runs a QA session where you walk through the full customer experience on a shared screen, and only then does Lever issue production credentials. Sandbox and production are separate apps, and callback URLs are registered per app, not per customer.

The token lifecycle is standard but strict. You request scopes space-separated, with offline_access to receive a refresh token. Access tokens return expires_in: 3600. Refresh tokens expire "after one year or after 90 days of inactivity," and each refresh returns a new refresh token that replaces the old one. A customer account your agent touches quarterly can lapse between runs.

Then the scopes. Lever's scope list runs from opportunities:read:admin to webhooks:write:admin, and every data scope ends in :admin; opportunities:write:admin reads "View and manage all opportunities." Lever's OAuth guide describes a Super Admin authorizing your app for their organization. The grant your agent holds is account-wide. There is also confidential:access:admin, and without it list endpoints default to non-confidential records, which is a quiet way for an agent to report "no candidates" for an executive search.

What the user can't do, the agent can't do. On Lever's official MCP Connector, Lever says it enforces that. On the API path, the token can do what a Super Admin can do, and the hiring manager asking the question is a row in your database, not a principal Lever knows about.

Both paths still need per-user isolation

In a multi-tenant B2B agent, MCP gives you a credential per user and the direct API gives you a credential per user. In neither case does the path itself solve storage, rotation, or revocation; those are infrastructure problems regardless of which path you choose.

Lever puts the isolation boundary in two places at once. Across customers, each connected account is one company's whole Lever account, so binding a grant to exactly one customer is the first line, and a mix-up exposes every candidate's resume, contact details, and interview feedback to the wrong company. Inside a customer, the grant sees every posting, so a hiring manager's copilot session must be narrowed to the postings that person may see. lever_posting_users_list returns the users and roles with access to a posting's candidates, which gives you Lever's own answer to check against. Candidate data is personal data, and EEO and diversity survey responses sit behind their own scopes for a reason.

Recommended reading: access control for multi-tenant AI agents

What you own in production

On the MCP path, with a community server you own the code: you pin the version, read the diff, and handle failures, because nobody else will. Rate limits apply per API key, so one recruiter's chat that pages through 30,000 archived opportunities eats the budget every other integration on that key shares. With the official connector, Lever owns the server and the permission checks, and you own nothing but the rollout. Neither MCP option learns anything on its own; tools run when someone asks.

On the API path, you own pagination, retries, and the token lifecycle. Lever rate-limits with a token bucket: 10 requests per second steady state per key, bursts to 20 when capacity allows, no endpoint-specific limits documented, and the defaults "are not guaranteed." Back off exponentially on 429. Ten requests per second sounds generous until a feedback chaser makes four calls per opportunity across 400 active candidates in one customer account.

Writes carry Lever's attribution model. Many write endpoints take perform_as, a Lever user UID that Lever records as the actor. Scalekit's docs mark it required for lever_opportunity_create and optional on tools like lever_note_create and lever_opportunity_stage_update, and Knit's API guide notes it is required for posting creation and file uploads. It is attribution, not authorization: the token's reach stays the same whichever user you name.

Two more Lever constraints shape agent writes. Notes created in the Lever app cannot be updated through the API, so lever_note_update only works on notes your integration made. And interviews can only be scheduled through the API on externally managed panels, which means panels your integration created with lever_panel_create, not ones a coordinator built in Lever's scheduler.

Webhooks are how a headless agent learns that something happened. A Super Admin configures them on the webhooks tab of the account's integration settings, and the API exposes webhook management under webhooks:write:admin. Events include applicationCreated, candidateHired, candidateStageChange, candidateArchiveChange, candidateDeleted, contactCreated, and contactUpdated, plus interview events in beta. Three properties shape the handler:

  1. The signature lives in the body and does not cover the data. You compute HMAC-SHA256 over token concatenated with triggeredAt, keyed with the account's 48-character signing token, and compare it to signature. Nothing in that scheme binds data, so a valid signature proves Lever sent a delivery with that token, not that opportunityId and toStageId are untouched. Re-read the opportunity before acting.
  2. Retries are bounded. A non-2xx response triggers up to five retries with increasing delays. After that, the delivery history keeps up to 1,000 requests from the past two weeks, and failed ones can be rerun by hand.
  3. Payloads are thin. A candidateStageChange carries opportunityId, contactId, fromStageId, and toStageId. Your agent fetches everything else.

When to use MCP, when to use the API

Use Lever MCP when:

  • A hiring manager in Copilot or Claude asks "where are my candidates for the Staff Designer role" and you want Lever's official connector to answer under their own permissions, once your Customer Account Manager has enabled it.
  • An interviewer wants candidate background and prior feedback before a loop, read-only, inside the chat tool they already use.
  • A recruiting ops lead at one company wants ad hoc pipeline queries through a community server on their own laptop, and someone owns pinning and reviewing its code.
  • You are prototyping a recruiting assistant against a Lever sandbox account before committing to the partner program.

Use the Lever API when:

  • Your product serves many customer companies, each connecting its Lever account through OAuth, so tokens are scoped to one account and revocable per customer.
  • A candidateStageChange webhook drives a deterministic pipeline: check feedback on the stage the candidate just left, then log a note with lever_note_create that @mentions the interviewers who still owe it.
  • The agent writes: lever_opportunity_stage_update after a recruiter approves, lever_opportunity_archive with a hired reason and requisition, or lever_panel_create for interviews your scheduler books.
  • You sync Lever into another system and need lever_opportunities_deleted_list to keep deletions honest, which no MCP option exposes.

The credential problem that exists on both paths

Both paths hand you a Lever credential per account or per user. Neither hands you a vault. A recruiting copilot serving 80 customer companies on the API path holds 80 OAuth grants, each authorized by a Super Admin, each with an access token that dies hourly and a refresh token that rotates on every use and lapses after 90 quiet days. Behind them sits the client secret Lever issued to your partner app. Every refresh token must be persisted atomically when it rotates, because two workers refreshing the same grant at once leave one of them holding a dead token. Every grant must be bound to one customer, kept out of logs and LLM context, and discarded when the customer disconnects.

The MCP path doesn't make the count smaller. Lever's official connector moves identity to each person's session, which matches Lever's permission model, but it is read-only and not yet available. The community servers move API keys onto laptops, where nobody can list or revoke them centrally. The tempting shortcut on the API path is asking each customer's admin to paste an API key into your settings page. Fewer partner hoops, larger blast radius: 80 keys with no documented expiry and account-wide reach, in your database.

Scalekit's lever connector runs the OAuth flow for each customer account, stores the tokens in the token vault, and refreshes them ahead of expiry, so the MCP vs API decision doesn't change your auth infrastructure. Your agent passes an identifier; credentials never touch the agent runtime or the LLM context.

Recommended reading: how to handle token refresh for AI agents

How to connect a Lever agent with Scalekit

Scalekit's lever connector is an API-path connector built by Scalekit with 77 tools: 46 read-only, 25 write, and 6 destructive (lever_feedback_delete, lever_file_delete, lever_interview_delete, lever_note_delete, lever_panel_delete, lever_requisition_delete). It covers opportunities and contacts, stages and archive reasons, tags, sources, links, notes, feedback and feedback templates, profile forms, interviews and panels, offers, referrals, resumes and files, postings and application questions, requisitions and requisition fields, diversity surveys, users, and deletion feeds. The core set for a recruiting copilot:

Tool
What it does
lever_users_list
Lists users with id, username, email, and accessRole; the source of perform_as IDs and @mention usernames
lever_postings_list
Lists postings by state, team, department, or location, with hiringManager and owner
lever_stages_list
Lists pipeline stages and their UIDs
lever_opportunities_list
Lists opportunities filtered by posting, stage, tag, archive state, or date range
lever_interviews_list
Lists an opportunity's interviews with interviewers, stage, and canceledAt
lever_feedback_list
Lists submitted feedback forms on an opportunity, with the interview and user
lever_note_create
Adds a note, with @mentions, an optional 1 to 4 score, and optional perform_as
lever_opportunity_stage_update
Moves an opportunity to a stage UID

The connector does not cover file uploads or webhook registration, and offers are read-only through lever_offers_list.

Prerequisites and the connection name

In the Scalekit dashboard, go to AgentKit > Connections, create a Lever connection, and copy its redirect URI. The name you give the connection is the connection_name your code passes, and the string must match the dashboard exactly. This is the most common integration error.

Lever connections use your own OAuth app, and for Lever that means the partner program: Lever registers the app, with your callback URIs and required scopes, and issues the Client ID and Client Secret. Register the Scalekit redirect URI as a callback, request the scopes your tools need plus offline_access, and keep the list tight; Lever caps an app at 20 scopes and says most integrations need 5 to 8. Build against a sandbox app first, then repeat with the production app Lever issues after QA. Enter the Client ID and Client Secret on the Lever connection. Copy SCALEKIT_ENVIRONMENT_URL, SCALEKIT_CLIENT_ID, and SCALEKIT_CLIENT_SECRET from Developers > API Credentials into .env. See the connector docs.

pip install scalekit-sdk-python python-dotenv langchain-openai

Authorize each customer once

get_or_create_connected_account returns the connected account for an identifier. If it is not ACTIVE, get_authorization_link returns the Lever consent link. Because the grant covers the customer's whole Lever account, the identifier is your customer's ID and the person who clicks should be one of their Lever Super Admins. After consent, the code resolves the Lever user your integration writes as. Many customers create a dedicated user such as recruiting-copilot@acme.com for this, so notes and stage moves show up under a name everyone recognizes. Store that user's UID; your code, not the model, passes it as perform_as.

import os from dotenv import load_dotenv from scalekit import ScalekitClient load_dotenv() scalekit_client = ScalekitClient( env_url=os.environ["SCALEKIT_ENVIRONMENT_URL"], client_id=os.environ["SCALEKIT_CLIENT_ID"], client_secret=os.environ["SCALEKIT_CLIENT_SECRET"], ) actions = scalekit_client.actions CONNECTION_NAME = "lever" # must match the connection name in AgentKit > Connections identifier = "customer_acme" # your app's stable ID for this customer's Lever account INTEGRATION_USER_EMAIL = "recruiting-copilot@acme.com" # Lever user that agent writes are attributed to account = actions.get_or_create_connected_account( connection_name=CONNECTION_NAME, identifier=identifier, ) if account.connected_account.status != "ACTIVE": link = actions.get_authorization_link( connection_name=CONNECTION_NAME, identifier=identifier, ) print("A Lever Super Admin should open:", link.link) input("Press Enter after approving access in Lever...") account = actions.get_or_create_connected_account( connection_name=CONNECTION_NAME, identifier=identifier, ) if account.connected_account.status != "ACTIVE": raise RuntimeError(f"Lever is {account.connected_account.status}, not ACTIVE") def rows(result_data) -> list: """Lever list responses wrap records in a data array; accept a bare list too.""" if isinstance(result_data, list): return result_data if isinstance(result_data, dict) and isinstance(result_data.get("data"), list): return result_data["data"] return [] users = actions.execute_tool( tool_name="lever_users_list", tool_input={"email": INTEGRATION_USER_EMAIL}, connection_name=CONNECTION_NAME, identifier=identifier, ) matches = rows(users.data) if not matches: raise RuntimeError(f"No active Lever user with email {INTEGRATION_USER_EMAIL}") PERFORM_AS_USER_ID = matches[0]["id"] print(users.execution_id, "perform_as user:", PERFORM_AS_USER_ID)

Retrieve the Lever tools this account can call

This is not a catalog load. The agent asks for the tools this connected account is authorized to call, then narrows them to the six a hiring manager's pipeline assistant needs. Because every Lever scope is account-wide, this list is where the agent's reach first gets smaller than a Super Admin's. lever_opportunity_archive, lever_opportunity_stage_update, and lever_requisition_delete never reach the model, so it cannot reject a candidate or delete a requisition while answering a status question.

tools = actions.langchain.get_tools( identifier=identifier, connection_names=[CONNECTION_NAME], tool_names=[ "lever_stages_list", "lever_opportunities_list", "lever_opportunity_get", "lever_interviews_list", "lever_feedback_list", "lever_user_get", ], page_size=100, ) tool_map = {t.name: t for t in tools} print(sorted(tool_map))

Run the agent loop

The tool list limits what the agent can do. It does not limit which candidates it can see, because lever_opportunities_list answers for the whole account. So before the loop, the code computes the hiring manager's postings and active opportunities with execute_tool, and the loop enforces them: list calls are pinned to those postings, and any call that names an opportunity outside the set is refused. The boundary lives in code, not in the prompt.

from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage from langchain_openai import ChatOpenAI HIRING_MANAGER_EMAIL = "priya.raman@acme.com" def paginate(tool_name: str, tool_input: dict) -> list: """Follow Lever's next/hasNext cursor through every page of a list tool.""" records, offset = [], None while True: page_input = dict(tool_input, limit=100) if offset: page_input["offset"] = offset page = actions.execute_tool( tool_name=tool_name, tool_input=page_input, connection_name=CONNECTION_NAME, identifier=identifier, ) data = page.data if isinstance(page.data, dict) else {} records.extend(rows(page.data)) if not data.get("hasNext"): return records offset = data.get("next") manager = rows( actions.execute_tool( tool_name="lever_users_list", tool_input={"email": HIRING_MANAGER_EMAIL}, connection_name=CONNECTION_NAME, identifier=identifier, ).data ) if not manager: raise RuntimeError(f"{HIRING_MANAGER_EMAIL} is not an active Lever user") manager_id = manager[0]["id"] postings = paginate("lever_postings_list", {"state": "published", "distributionChannel": ["public", "internal"]}) allowed_postings = [p["id"] for p in postings if p.get("hiringManager") == manager_id] if not allowed_postings: raise RuntimeError("This hiring manager has no published postings") opportunities = paginate("lever_opportunities_list", {"posting_id": allowed_postings, "archived": False}) allowed_opportunities = {o["id"] for o in opportunities} llm = ChatOpenAI(model="gpt-4o").bind_tools(tools) messages = [ SystemMessage( "You help a hiring manager understand their Lever pipeline. Use lever_stages_list " "to map stage names to stage UIDs. Use lever_opportunities_list with stage_id and " "advanced_at_end to find candidates who entered a stage before a cutoff; all " "timestamps are Unix milliseconds. For each candidate, use lever_interviews_list and " "lever_feedback_list to compare interviewers against submitted feedback, and " "lever_user_get to turn user IDs into names. If a response has hasNext true, pass its " "next value as offset. Never guess IDs and never claim you changed anything." ), HumanMessage( "Which of my candidates have been in their current stage for more than 7 days, and " "which interviewers still owe feedback on them?" ), ] while True: response = llm.invoke(messages) messages.append(response) if not response.tool_calls: print(response.content) break for tc in response.tool_calls: name, args = tc["name"], dict(tc["args"]) tool = tool_map.get(name) if tool is None: result = f"Unknown tool: {name}" elif name == "lever_opportunities_list": requested = args.get("posting_id") or allowed_postings args["posting_id"] = [p for p in requested if p in allowed_postings] or allowed_postings result = tool.invoke(args) elif "opportunity_id" in args and args["opportunity_id"] not in allowed_opportunities: result = "Refused: that opportunity is not on this hiring manager's postings." else: result = tool.invoke(args) messages.append(ToolMessage(content=str(result), tool_call_id=tc["id"]))

Deterministic calls with execute_tool

For pipelines, skip the model. execute_tool runs one tool with a fixed input and returns data plus an execution_id you can log. This handler runs when a customer's Lever account sends candidateStageChange. It verifies the body signature, then treats the payload as a hint, because the signature covers token and triggeredAt but not data. It re-reads the opportunity and acts only if Lever confirms the candidate is now in toStageId. Then it finds non-canceled interviews from the stage the candidate left, compares their interviewers with submitted feedback, and logs one note that @mentions whoever still owes it. The note is attributed to the integration user resolved at setup, set by code.

import hashlib import hmac import json import time PROCESSED_DELIVERIES = set() # use a durable store with a TTL in production def verify_lever_signature(payload: dict, signing_token: str) -> bool: """Lever signs token + triggeredAt with HMAC-SHA256, keyed by the account's signing token.""" message = f"{payload.get('token', '')}{payload.get('triggeredAt', '')}".encode() expected = hmac.new(signing_token.encode(), message, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, str(payload.get("signature", ""))) def lever_record(result_data) -> dict: """Single-record tools return the object, possibly wrapped in data.""" if isinstance(result_data, dict) and isinstance(result_data.get("data"), dict): return result_data["data"] return result_data if isinstance(result_data, dict) else {} def handle_stage_change(raw_body: bytes, customer_id: str, signing_token: str, perform_as: str) -> None: payload = json.loads(raw_body) if not verify_lever_signature(payload, signing_token): raise PermissionError("Invalid Lever webhook signature") if payload.get("event") != "candidateStageChange": return # still answer 2xx so Lever does not retry delivery_key = (payload.get("token"), payload.get("triggeredAt")) if delivery_key in PROCESSED_DELIVERIES: return PROCESSED_DELIVERIES.add(delivery_key) data = payload.get("data", {}) opportunity_id = data.get("opportunityId") from_stage_id, to_stage_id = data.get("fromStageId"), data.get("toStageId") if not (opportunity_id and from_stage_id and to_stage_id): return opportunity = actions.execute_tool( tool_name="lever_opportunity_get", tool_input={"opportunity_id": opportunity_id}, connection_name=CONNECTION_NAME, identifier=customer_id, ) current = lever_record(opportunity.data) current_stage = current.get("stage") if isinstance(current_stage, dict): current_stage = current_stage.get("id") if current_stage != to_stage_id or current.get("archived"): return # payload does not match Lever's current state; do nothing print(opportunity.execution_id, opportunity_id, from_stage_id, "->", to_stage_id) interviews = rows( actions.execute_tool( tool_name="lever_interviews_list", tool_input={"opportunity_id": opportunity_id, "limit": 100}, connection_name=CONNECTION_NAME, identifier=customer_id, ).data ) feedback = rows( actions.execute_tool( tool_name="lever_feedback_list", tool_input={"opportunity_id": opportunity_id, "limit": 100}, connection_name=CONNECTION_NAME, identifier=customer_id, ).data ) now_ms = int(time.time() * 1000) submitted = {(f.get("interview"), f.get("user")) for f in feedback if f.get("completedAt")} pending_user_ids = set() for interview in interviews: stage = interview.get("stage") stage_id = stage.get("id") if isinstance(stage, dict) else stage if stage_id != from_stage_id or interview.get("canceledAt"): continue if (interview.get("date") or 0) > now_ms: continue # not held yet for interviewer in interview.get("interviewers", []): if (interview.get("id"), interviewer.get("id")) not in submitted: pending_user_ids.add(interviewer.get("id")) pending_user_ids.discard(None) if not pending_user_ids: return mentions = [] for user_id in sorted(pending_user_ids): user = lever_record( actions.execute_tool( tool_name="lever_user_get", tool_input={"user_id": user_id}, connection_name=CONNECTION_NAME, identifier=customer_id, ).data ) if user.get("username"): mentions.append(f"@{user['username']}") note = actions.execute_tool( tool_name="lever_note_create", tool_input={ "opportunity_id": opportunity_id, "value": ( f"{current.get('name', 'This candidate')} moved to the next stage with " f"{len(pending_user_ids)} interviewer(s) yet to submit feedback from the previous " f"stage: {' '.join(mentions) or 'interviewers without a Lever username'}. " "Please submit your feedback today." ), "perform_as": perform_as, "notifyFollowers": False, }, connection_name=CONNECTION_NAME, identifier=customer_id, ) print(note.execution_id, note.data)

The signing token is per Lever account, so it belongs in your own store keyed by customer, next to the integration user's UID. Webhook setup is not a connector tool: a Super Admin enables candidateStageChange on the webhooks tab and points it at your endpoint. Lever retries up to five times, so the dedupe set must survive restarts.

Stage moves follow the same pattern with one difference: they should wait for a person. lever_opportunity_stage_update takes the opportunity and a stage UID, and is idempotent. Run it after a recruiter clicks approve in your product, with perform_as set to that recruiter's Lever UID, so Lever's activity feed shows the human who made the decision.

def approve_stage_move(customer_id: str, opportunity_id: str, stage_id: str, recruiter_lever_id: str) -> dict: """Called from your approval endpoint after a recruiter confirms the move in your UI.""" result = actions.execute_tool( tool_name="lever_opportunity_stage_update", tool_input={ "opportunity_id": opportunity_id, "stage": stage_id, "perform_as": recruiter_lever_id, }, connection_name=CONNECTION_NAME, identifier=customer_id, ) print(result.execution_id, opportunity_id, "->", stage_id, "by", recruiter_lever_id) return lever_record(result.data)

Reaching the Lever REST API

Some work belongs in a plain script against your own company's Lever, such as seeding a sandbox with sourced candidates before a demo. Here it is against the API directly with an API key and Basic auth. POST /opportunities requires perform_as as a query parameter, and Lever dedupes by email, so the script checks first. For customer accounts, the same calls go through Scalekit with each account's OAuth grant, and custom connectors cover authenticated REST calls the prebuilt tools don't, such as file uploads.

import os import time import requests LEVER_API = "https://api.sandbox.lever.co/v1" # https://api.lever.co/v1 in production API_KEY = os.environ["LEVER_API_KEY"] # never ship this to a browser or an LLM PERFORM_AS = os.environ["LEVER_PERFORM_AS_USER_ID"] auth = (API_KEY, "") # key as username, blank password def lever_request(method: str, path: str, **kwargs) -> requests.Response: for attempt in range(5): response = requests.request(method, f"{LEVER_API}{path}", auth=auth, timeout=30, **kwargs) if response.status_code != 429: response.raise_for_status() return response time.sleep(2 ** attempt) # token bucket: 10 rps steady, bursts to 20 raise RuntimeError("Lever kept returning 429; slow this script down") candidate = { "name": "Shane Smith", "headline": "Brickly LLC, Vandelay Industries", "emails": ["shane@example.com"], "links": ["https://linkedin.com/in/shane-smith"], "tags": ["Demo seed"], "sources": ["Sourced"], "origin": "sourced", } existing = lever_request("GET", "/opportunities", params={"email": candidate["emails"][0]}) if existing.json().get("data"): print("Already in Lever:", existing.json()["data"][0]["id"]) else: created = lever_request( "POST", "/opportunities", params={"perform_as": PERFORM_AS}, json=candidate, ) print("Created opportunity:", created.json()["data"]["id"])

Pass confidential=all on list calls only if the key was created with confidential access; otherwise Lever returns non-confidential records and says nothing about the rest.

Multi-tool, multi-tenant Lever agents on a virtual MCP server

A recruiting copilot rarely stops at Lever. It chases feedback, then posts a digest to the hiring channel in Slack. Load all 77 lever tools and the model holds lever_opportunity_archive, lever_requisition_delete, and lever_contact_update while answering "who owes feedback on Onsite." At roughly 200 tokens each, 77 tools is about 15,400 tokens before the agent does any work, and with every Lever scope account-wide, the tool list is the main thing standing between a misread request and an archived candidate. The fix is not better prompting. It is surface reduction. A Scalekit virtual MCP server declares exactly which tools an agent role can see across connections, and session tokens keep each run bound to one customer's connected accounts. Docs: set up a virtual MCP server.

Create the server once per agent role

This role reads and reports. It leaves out lever_note_create, because its perform_as input would let the model choose which Lever user a note appears to come from. Writes with attribution stay in the execute_tool pipeline above, where code sets that field.

from scalekit.actions.models.mcp_config import McpConfigConnectionToolMapping vmcp = actions.mcp.create_config( name="lever-feedback-digest", description="Reads Lever interviews and feedback and posts a daily Slack digest of overdue scorecards", connection_tool_mappings=[ McpConfigConnectionToolMapping( connection_name="lever", tools=[ "lever_opportunities_list", "lever_interviews_list", "lever_feedback_list", "lever_user_get", ], ), McpConfigConnectionToolMapping( connection_name="slack", tools=["slack_send_message"], ), ], ) print(vmcp.config.id, vmcp.config.mcp_server_url)

Mint a session token per customer, per run

One server definition serves every customer. Before each run, check that the customer's accounts are active and mint a short-lived session token scoped to them. With Lever's 90-day inactivity limit on refresh tokens, this check is where a lapsed grant surfaces as an authorization link for the customer's Super Admin, not as a failed run. Any MCP client that speaks Streamable HTTP can connect; this example uses langchain-mcp-adapters (pip install langchain-mcp-adapters) and passes the token as a Bearer header.

import asyncio from datetime import timedelta from langchain_mcp_adapters.client import MultiServerMCPClient config_id = vmcp.config.id mcp_server_url = vmcp.config.mcp_server_url customer_id = "customer_acme" accounts = actions.mcp.list_mcp_connected_accounts( config_id=config_id, identifier=customer_id, include_auth_link=True, ) not_ready = [ a for a in accounts.connected_accounts if str(a.connected_account_status).upper() != "ACTIVE" ] for a in not_ready: print(f"{a.connection_name} is {a.connected_account_status}: {a.authentication_link}") if not_ready: raise RuntimeError("Connect the accounts above before starting this run") session_token = actions.mcp.create_session_token( mcp_config_id=config_id, identifier=customer_id, expiry=timedelta(minutes=30), ).token async def load_tools(): client = MultiServerMCPClient( { "scalekit": { "transport": "streamable_http", "url": mcp_server_url, "headers": {"Authorization": f"Bearer {session_token}"}, } } ) return await client.get_tools() mcp_tools = asyncio.run(load_tools()) print([t.name for t in mcp_tools])

The agent sees five tools instead of 77 plus everything Slack exposes. The endpoint is static; the identity is per customer. Compared with Lever's announced connector, this endpoint is available now, can include write tools for roles that should hold them, and works for agents that run on a schedule rather than inside one person's chat.

Observability: every Lever action tied to a customer

Every execute_tool call returns an execution_id, and every execution is attributed to a connected account, which maps to one customer's Lever account. When a recruiting lead asks which agent run posted 40 feedback reminders overnight or moved a candidate to Offer on a Sunday, the answer is a lookup, not an investigation.

Lever records the other half. Writes land in Lever under the perform_as user, and Lever exposes audit events through the API under audit_events:read:admin. That makes perform_as the most consequential field an agent touches: whoever controls it decides whose name appears in a customer's Lever history. Keep it out of the model's hands, set it from your own mapping of signed-in user to Lever UID, and log it beside the execution_id. On a community MCP server the picture is flatter: every action runs on one API key, and nothing records which teammate asked for it. Shared credentials are a single-user solution. They do not survive a second user.

Recommended reading: audit trails for agent auth

Which one to build against

If your agent is a hiring manager's read-only assistant inside Copilot or Claude, Lever's official MCP Connector is the right target once it ships: Lever enforces each person's permissions and you run nothing. Until then, a community server with one API key works for a single team that accepts what that key can reach. If you are building a recruiting copilot into a product for many customer companies, build on the Lever API with OAuth, plan for the partner program's sandbox and QA steps, and enforce posting-level visibility yourself, because the grant is account-wide. If your agent reacts to stage changes, writes notes and stage moves, or schedules interviews, the API is the only surface that does it. Either way, you hold an :admin-scoped Lever grant per customer with rotating refresh tokens, and that's what needs production-grade infrastructure.

FAQs

Does Lever have an official MCP server?

Lever has announced the Lever MCP Connector for Claude, Copilot, ChatGPT and Slack, but its product page still says coming soon and publishes no endpoint or tool list. Lever states access will be read-only and scoped to each person's existing Lever permissions, with setup through your Customer Account Manager.

Are community Lever MCP servers safe to use?

They work for one team that accepts their limits. Every community server reviewed reads a single Lever API key from the environment, so every request runs with that key's reach, the key has no documented expiry and it sits in a config file on someone's laptop. One of the two examined is listed as inactive.

How does Lever OAuth work for integrations?

OAuth is part of Lever's partner program. Lever registers your app, you build against a sandbox, Lever runs a QA session and then issues production credentials. Access tokens last one hour, refresh tokens rotate on every use and expire after one year or 90 days of inactivity, and every data scope ends in admin.

Why do Lever's admin scopes matter for agents?

An OAuth grant reaches the whole Lever account, not one recruiter's postings, so the hiring manager asking a question is a row in your database rather than a principal Lever knows about. The agent layer has to narrow visibility itself, for example by pinning list calls to the postings that manager owns.

What can the Lever API do that MCP options cannot?

The API moves candidates between stages, archives them with hire reasons, writes notes with mentions, schedules interviews on externally managed panels, lists deletions for sync and delivers stage-change webhooks. The official connector is read-only and community servers run only inside a chat, so headless agents and reliable writes belong on the API.

How does Scalekit connect an AI agent to Lever?

Scalekit's Lever connector runs the OAuth flow for each customer account through your own Lever partner app, stores the tokens in a vault and refreshes them ahead of expiry. It exposes 77 tools, 46 read, 25 write and 6 destructive, which you narrow per agent role and serve through a virtual MCP server.

Build your Lever agent with Scalekit

Browse the Scalekit Lever connector and the Lever connector docs with the full tool list. See Scalekit pricing, or talk to us.

No items found.
On this page
Share this article
Scalekit
Find us on ProductHunt

Acquire enterprise customers with
‍zero upfront cost.

Every feature unlocked. No hidden fees.