Announcing CIMD support for MCP Client registration
Learn more

MCP vs API for Integrating Workable to AI Agents

Hrishikesh Premkumar
Founding Architect

Workable ships a hosted MCP server that runs every call as the signed-in member, and a REST API reached through admin-generated account tokens on each customer's own subdomain. The two overlap heavily on jobs and candidates. They split on identity: one path knows who is acting, the other needs your code to say so on every write. This guide compares what each path lets a recruiting agent do, how attribution works, and how to run either across many customer companies in production.

TL;DR

  • Workable hosts an official Model Context Protocol (MCP) server at https://mcp.workable.com/mcp. It went GA on May 13, 2026 with 38 tools on every plan, and its reference now lists 94: jobs, candidates, offers, requisitions, members, employees, time off, time tracking, and performance reviews. It authenticates each member with OAuth 2.0 and runs every call as that member, limited to their role and job assignments.
  • The Workable API v3 lives on each customer's own subdomain (https://acme.workable.com/spi/v3/). Self-serve access means an account token that only an admin can generate, with scopes such as r_jobs, r_candidates, and w_candidates, and an expiry between 30 days and 2 years. OAuth 2.0 and partner tokens exist, but only for approved Workable partners.
  • An account token is not a person. Write endpoints take a member_id that Workable records as the actor, and HR endpoints use it to decide whose access level applies. Whoever controls that field controls whose identity the agent borrows, so in a multi-user agent it must come from your code, never from the model.
  • MCP is the right path for a recruiter or hiring manager in Claude, ChatGPT, or Cursor working their own pipeline. The API is the right path for headless agents driven by candidate_created and candidate_moved webhooks, for products serving many customer companies, and for writes the MCP server lacks, such as creating employee records. Budget for the account-token limit of 10 requests per 10 seconds.
  • Scalekit's workable connector exposes 66 prebuilt tools over the Workable API (40 read, 26 write, none destructive). Each customer connects their Workable access token once, Scalekit stores it in the token vault, and the agent never sees it, so the MCP vs API decision doesn't change your auth infrastructure.

The decision your Workable agent forces first

Your product sits next to your customers' applicant tracking system. Its agent needs to tell a recruiting lead which Senior Backend Engineer candidates have sat in phone screen for more than a week, drop a screening summary on every new applicant's timeline, flag a candidate who already applied to a different role last quarter, and move people forward once an interview panel signs off. Every customer runs their own Workable account on their own subdomain. Workable now hosts an official MCP server and markets it to exactly these users. It also has a REST API with scoped tokens and signed webhooks. The two overlap heavily on candidates and jobs. They split on identity: one runs as the signed-in member, the other as an account-wide token that you have to attribute yourself. Here's how to pick.

What Workable MCP and Workable API actually are

Workable MCP

The Workable MCP server is hosted by Workable at https://mcp.workable.com/mcp over Streamable HTTP, in what its docs call stateless JSON mode, with no SSE. Nothing runs locally. Workable announced general availability on May 13, 2026 with 38 tools, available on all plans at no extra cost, and by August 2026 it had grown to 94. The additions include advanced candidate and employee search, performance review cycles, member administration, and candidate write tools. The two advanced search capabilities require a Premier+ or Enterprise plan.

Clients authenticate with OAuth 2.0. The server publishes /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource and supports Dynamic Client Registration (DCR) under RFC 7591. Workable's help center has guides for Claude, ChatGPT, Cursor, and Microsoft Copilot Studio, and adds one catch: redirect URIs must be on a pre-approved list, and an unlisted one fails with a redirect URI mismatch until Workable Support adds it.

The first call is get_accounts, which lists every Workable account the member can reach. Every other tool takes that account's subdomain as an account parameter. Representative tools include get_candidates, search_candidates_detailed, move_candidate, disqualify_candidate, add_comment, upsert_candidate_rating, approve_offer, create_requisition, get_timeoff_requests, clock_in, and submit_review.

Workable API

The Workable API v3 is REST at https://{subdomain}.workable.com/spi/v3/, where the subdomain is the customer's account, found under Settings > Company profile. It covers jobs and their stages, members, recruiters, and application questions; candidates, their activities, files, comments, ratings, tags, and custom attributes; offers; requisitions under Workable's Hiring Plan; departments and legal entities; scheduled events such as interviews; employees, documents, and the org chart; time off; time tracking; and webhook subscriptions. Lists page with since_id and max_id and return an absolute paging.next URL.

Three token types reach it:

  • Account token: generated under Settings > Integrations > Apps > API Access Tokens. Workable's help article says only admins can generate one. Each token gets at least one scope, from r_account, r_jobs, r_candidates, r_requisitions, r_employees, and r_timeoff to w_candidates, w_comments, w_departments, w_employees, w_members, w_requisitions, and w_timeoff, and an expiry of 30 days, 90 days, 6 months, 1 year, or 2 years. It is shown once and sent as Authorization: Bearer.
  • OAuth 2.0: an authorization_code flow with rotating refresh tokens at https://www.workable.com/oauth/token. Workable's OAuth page restricts it to officially approved partners, who receive a client_id and client_secret from Workable.
  • Partner token: a scoped token for official third-party integrations, sent with an X-WORKABLE-CLIENT-ID header. Also partner-only, per the partner token page.

Comparing them where it matters for agents

What your agent can actually do

Capability
Workable MCP (mcp.workable.com)
Workable API v3 (/spi/v3)
Vendor-maintained surface
Yes: Hosted by Workable, GA since May 2026
Yes: Workable-owned REST contract
Jobs, stages, and hiring teams
Yes
Yes
Find candidates
Yes: Multi-field candidate search on Premier+ or Enterprise
Limited: Filter by job, stage, email, and created or updated time
Move, disqualify, comment, rate
Yes: As the signed-in member
Yes: Account tokens must name the acting member
Create candidates with application answers
Yes
Yes
Approve or reject offers
Yes
Limited: Works only with user-level tokens, not account tokens
Requisitions (Hiring Plan)
Yes: Create, update, approve, reject
Yes: Create and approve, plus custom attribute choices
Create or update employee records
Limited: Read and search only; no create or update tool
Yes
Performance reviews
Yes
Limited: Review templates only
Member administration
Yes
Limited: Read-only in Scalekit; the raw API needs a member write scope
React to new and moved candidates
No: Tools run only inside a conversation
Yes: Subscriptions for created and moved candidates, employee updates and more
Several Workable accounts per user
Yes: List accounts, then name the account on every call
Limited: One token per subdomain

The MCP column is wider than most vendor servers in this series. Workable did not ship a read-only demo; it ships stage moves, disqualifications, offer approvals, requisition approvals, member invites, and review sign-offs. For a recruiter in Claude, the gap to the API is small. The gaps that remain are structural: there is no event delivery, no way to create an employee record, and no way to run without a human starting the conversation.

The API column has its own gap, and it comes from the token, not the endpoints. Offer approval through the API requires a user-level token, which a self-serve account token is not. An agent that approves offers on the API path needs either Workable partner status or a human approving in Workable itself.

What the MCP path makes easier

"Which Senior Backend Engineer candidates have been in phone screen for more than seven days" on the raw API means listing jobs to find the shortcode, reading that job's stages to learn the exact slug Workable uses for phone screen at this customer, listing candidates filtered by shortcode and stage, following paging.next, and reading each candidate's activity stream to find when they were moved into the stage. At 10 requests per 10 seconds on an account token, 40 candidates takes over 40 seconds of throttled calls, and every response lands in the model's context.

Workable MCP handles the identity and account plumbing for you. get_accounts resolves which subdomain to use, search_jobs finds a job by partial title and returns its shortcode, and search_candidates_detailed runs a filtered, sorted query across open jobs in one call on the plans that include it. Setup is a custom connector URL and a Workable sign-in. Writing the schema is the hard part. Not the API call.

The tradeoff is that Workable owns the surface, and it moves fast: 38 tools in May, 94 by August. Some of those tools carry hard edges that its own docs call out. submit_review and sign_review are final. update_candidate_tags replaces the whole tag list. Updating an approved requisition sends it back for approval. An agent tuned against today's tool set changes behavior when Workable ships, without a release on your side.

The auth path each one puts you on

On the MCP path, auth is OAuth in the browser, and the result is a member-level identity. Workable states that each session is limited to the authenticated user's role and job assignments: a hiring manager collaborating on two jobs sees those two pipelines, a reviewer sees what reviewers see, and actions are recorded under that member. Workable's own clients need no setup. Your own agent registers itself through DCR, but its redirect URI still has to be on Workable's approved list.

On the direct API path, self-serve access means an account token. An admin generates it, picks scopes, and picks an expiry. It is tied to the account, not to the admin's job assignments, so a token with r_candidates reads every candidate in every job, including confidential roles a given recruiter was never assigned. Renewing an expired token keeps the same token value, which is convenient for integrations and means a leaked token that gets renewed stays leaked.

Writes on an account token raise a question MCP never does: who did this? Workable answers with a parameter. Moving, disqualifying, and reverting a candidate all require a member_id in the body, described as "the member performing" the action. Scalekit's connector docs add that on HR endpoints such as employee reads and updates, member_id is the "member whose access level is used." The account token proves your integration may act. The member_id decides whose name goes on the action and, for HR data, whose permissions filter the result.

That makes member_id an authorization input. Let the model fill it, and a prompt that says "do this as the head of people" can read employee records with an HR admin's access level. What the user can't do, the agent can't do, but only if your code, not the model, chooses the member.

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.

Workable shifts where the boundary sits on the API path. Because account tokens are admin-generated and account-wide, the realistic unit is one credential per customer company, not one per recruiter. Per-user isolation then lives in two places you build: the mapping from your signed-in user to their Workable member_id, and the tool surface you expose to the model for that user. Get the first wrong and actions are attributed to the wrong recruiter. Get the second wrong and a hiring manager's question runs with access to every confidential requisition in the account.

Recommended reading: access control for multi-tenant AI agents

What you own in production

On the MCP path, Workable hosts the server, owns the schemas, and enforces each member's access. You own the OAuth session in each client, the redirect URI approval if you bring your own client, and the gaps. Nothing happens when a candidate applies at 03:00, because tools only run inside a conversation. Writes are live and some are irreversible, so a human confirming each one is part of the design, not a nicety.

On the API path, you own pagination, retries, the token lifecycle, and attribution. Workable's rate limits are 10 requests per 10 seconds for account tokens and 50 for OAuth and partner tokens, enforced per client, with X-Rate-Limit-Limit, X-Rate-Limit-Remaining, and X-Rate-Limit-Reset on every response and 429 past the limit. Ten requests per 10 seconds is shared by every workload on that customer's token: your agent, your nightly sync, and the reconciliation job. Token expiry is a calendar problem, not a refresh problem. A token issued for 90 days stops working on day 91 unless an admin at the customer renews it, so you need expiry tracking per customer and a way to ask their admin before it lapses.

Webhooks are how a headless agent learns that something happened. You create one with POST /spi/v3/subscriptions, a target URL, and one event. Workable lists candidate_created, candidate_moved, candidate_deleted, job_deleted, employee_created, employee_updated, employee_published, onboarding_completed, and timeoff_updated. Four properties shape the handler:

  1. One event per subscription, and target URLs must be unique. Registering a URL that already exists returns 409 Conflict, so each event at each customer needs its own path.
  2. The signing key is a credential. Workable signs the payload with HMAC SHA256 in the X-Workable-Signature header, keyed with "the account token or the oauth application secret." To verify deliveries, your webhook service must hold the token that created the subscription.
  3. The payload is current state, not event state. Workable POSTs the candidate as it looks when the request is sent, which can differ from the moment the event fired. Re-read anything you act on.
  4. Anything other than 200 is replayed. Workable documents no retry count or schedule, so handlers must be idempotent, and a reconciliation job on updated_after covers what never arrives.

When to use MCP, when to use the API

Use Workable MCP when:

  • A recruiter in Claude or ChatGPT asks which candidates in their own jobs have gone quiet in a stage, and Workable's role and job-assignment rules decide what they see.
  • A hiring manager wants to read scorecards, add a rating with upsert_candidate_rating, and approve the offer that is waiting on them with approve_offer, confirming each step.
  • An HR lead runs a performance review cycle from a chat window, using the 15 review tools with a person signing off on every submit_review and sign_review.
  • You are prototyping a recruiting assistant for your own company, and each person signing in under their own Workable access is exactly the isolation you want.

Use the Workable API when:

  • A candidate_created webhook drives a deterministic pipeline: check for duplicates across jobs, tag the candidate, and post a screening note on their timeline before a recruiter opens the profile.
  • Your product serves many customer companies, each on its own subdomain, and you need one credential per customer, signed webhooks per customer, and backoff that respects a 10-request window.
  • The workflow writes HR records the MCP server can't, such as workable_employee_create when a candidate is hired or workable_employee_documents_add for signed paperwork.
  • The agent runs on a schedule with no one in a chat window, such as a weekly stale-pipeline digest posted to each hiring team's Slack channel.

The credential problem that exists on both paths

Both paths hand you a Workable credential. Neither hands you a vault. A recruiting product serving 80 customer companies holds 80 account tokens on the API path, each generated by an admin, each able to read every candidate in that account, each with its own expiry date between 30 days and 2 years away. Add the webhook signing key, which is itself a token, and a careful setup holds 160. Each must be stored encrypted, kept out of logs and LLM context, mapped to exactly one subdomain, tracked against its expiry, and deleted when the customer churns. Each also needs a member map: 80 customers with 12 recruiters and hiring managers each is 960 member_id mappings that decide who gets credit, and blame, for every write.

The MCP path changes the shape, not the problem. Those 960 people each hold an OAuth session in a chat client, which Workable scopes correctly and you cannot list, audit, or revoke centrally from your product. The tempting shortcut on the API path is one token with every scope ticked. Fewer decisions, larger blast radius: that token can read confidential requisitions, employee salaries, and time off records for the whole company, and every recruiter's request runs with it.

Scalekit's workable connector stores each customer's Workable access token in the token vault and injects it into every call, 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: OAuth vs API keys for AI agents

How to connect a Workable agent with Scalekit

Scalekit's workable connector is an API-path connector built by Scalekit with 66 tools: 40 read-only, 26 write, and none destructive. It covers jobs, stages, questions, members, and recruiters; candidates, activities, files, comments, ratings, tags, custom attributes, copies, and relocations; offers; requisitions and their custom attributes; departments, legal entities, and work schedules; events; employees, fields, documents, and the org chart; time off; time tracking; and review templates. The core set for a pipeline assistant:

Tool
What it does
workable_members_list
Lists account members; the source of every member_id
workable_jobs_list
Lists jobs, filterable by state; the source of every job shortcode
workable_job_stages_list
Lists one job's pipeline stages and their slugs
workable_candidates_list
Lists candidates, filterable by job, stage, email, and created or updated time
workable_candidate_get
Returns one candidate's full profile
workable_candidate_activities_list
Returns a candidate's activity stream, including stage moves
workable_events_list
Lists scheduled interviews, calls, and meetings
workable_candidate_comment_create
Adds a comment to a candidate's timeline, with role-based visibility

The connector authenticates with a Workable access token. There is no OAuth consent screen and no Scalekit-managed Workable app.

Prerequisites and the connection name

In the Scalekit dashboard, go to AgentKit > Connections and create a Workable connection. The name you give it is the connection_name your code passes, and the string must match the dashboard exactly. This is the most common integration error. Copy SCALEKIT_ENVIRONMENT_URL, SCALEKIT_CLIENT_ID, and SCALEKIT_CLIENT_SECRET from Developers > API Credentials into .env. See the connector docs.

Plan who generates the token at each customer, because only a Workable admin can. Ask for the narrowest scopes your agent needs: for the examples below, r_jobs (which also covers members and stages), r_candidates, and w_comments, with w_candidates only if the agent moves or tags candidates. Pick an expiry your customer success team can track; 1 year is a reasonable default.

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 a link to Scalekit's Hosted Connect page, where the customer's Workable admin enters the access token instead of approving an OAuth screen. Scalekit encrypts and stores it; your code never receives the token. The identifier here is your ID for the customer company, because an account token belongs to the account, not to one recruiter.

The first call after connecting does double duty. workable_members_list filtered by email confirms the token works against the right account and resolves the signed-in recruiter to their Workable member_id, which every write needs.

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 = "workable" # must match the connection name in AgentKit > Connections identifier = "cust_northwind" # your app's stable ID for this customer company 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("Ask a Workable admin to open this link and enter the access token:", link.link) input("Press Enter after the token is submitted...") account = actions.get_or_create_connected_account( connection_name=CONNECTION_NAME, identifier=identifier, ) if account.connected_account.status != "ACTIVE": raise RuntimeError(f"Workable is {account.connected_account.status}, not ACTIVE") # Resolve the signed-in recruiter to a Workable member, from your own session data signed_in_email = "priya.raman@northwind.example" members = actions.execute_tool( tool_name="workable_members_list", tool_input={"email": signed_in_email, "status": "active"}, connection_name=CONNECTION_NAME, identifier=identifier, ) member_list = members.data.get("members", []) if isinstance(members.data, dict) else [] if not member_list: raise PermissionError(f"{signed_in_email} is not an active member of this Workable account") member_id = member_list[0]["id"] print(members.execution_id, "acting member:", member_id, member_list[0].get("roles"))

Store member_id against your user record. If the recruiter is deactivated in Workable, this lookup fails, and the agent should stop acting for them.

Retrieve the Workable tools this account can call

This is not a catalog load. The agent asks for the tools this customer's connected account is authorized to call, then narrows them to the seven a pipeline assistant needs. Because an account token reaches the whole account, this list is where the agent's reach becomes smaller than the token's. workable_candidate_disqualify, workable_candidate_tags_update, workable_employee_update, and workable_requisition_approve never reach the model, so it cannot reject a candidate or wipe their tags while answering a question about stage times. The one write it keeps, workable_candidate_comment_create, is additive and visible on the candidate's timeline.

tools = actions.langchain.get_tools( identifier=identifier, connection_names=[CONNECTION_NAME], tool_names=[ "workable_jobs_list", "workable_job_stages_list", "workable_candidates_list", "workable_candidate_get", "workable_candidate_activities_list", "workable_events_list", "workable_candidate_comment_create", ], page_size=100, ) tool_map = {t.name: t for t in tools} print(sorted(tool_map))

Run the agent loop

The loop runs until the model stops requesting tools. Every call runs with this customer's token. The loop overwrites member_id on every tool call that accepts one, so the model can never choose whose identity a comment is posted under, whatever the prompt says. The system prompt carries what trips models up on this API: per-job stage slugs, paging.next cursors, and the 10-request window.

from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage from langchain_openai import ChatOpenAI MEMBER_SCOPED_TOOLS = {"workable_candidate_comment_create"} llm = ChatOpenAI(model="gpt-4o").bind_tools(tools) messages = [ SystemMessage( "You help a recruiter review their Workable pipeline. Find a job's shortcode with " "workable_jobs_list (state 'published'), then read its stage slugs with " "workable_job_stages_list; slugs differ by job and by company, so never guess one. " "List candidates with workable_candidates_list using shortcode and stage. When a " "response has paging.next, copy the since_id from that URL into the next call. " "To find when a candidate entered their current stage, call " "workable_candidate_activities_list with actions 'moved' and read the latest " "created_at. The account allows 10 API calls per 10 seconds, so check activities " "only for candidates you need. member_id is set by the system; pass any value. " "Only post a comment when the user asks for one." ), HumanMessage( "Which Senior Backend Engineer candidates have been in phone screen for more than " "seven days? List them with days in stage and their next scheduled interview, if " "any, and add a comment on each one saying they were flagged as stale today." ), ] while True: response = llm.invoke(messages) messages.append(response) if not response.tool_calls: print(response.content) break for tc in response.tool_calls: tool = tool_map.get(tc["name"]) if tool: args = dict(tc["args"]) if tc["name"] in MEMBER_SCOPED_TOOLS: args["member_id"] = member_id # identity comes from your session, not the model result = tool.invoke(args) else: result = f"Unknown tool: {tc['name']}" 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 Workable delivers candidate_created to a per-customer URL such as /hooks/workable/cust_northwind/candidate_created, which also satisfies Workable's unique-target rule. It verifies X-Workable-Signature, re-reads the candidate because the payload is current state rather than event state, checks for earlier applications with the same email, merges a returning-applicant tag into the existing tags because workable_candidate_tags_update replaces the whole list, and posts a note visible to recruiters and admins. Writes are attributed to a dedicated integration member each customer creates in Workable, so the timeline shows the agent, not a random recruiter.

import hashlib import hmac import json # Per customer: the Workable member that agent writes are attributed to, # and the secret-store key for the token that created the webhook subscription CUSTOMERS = { "cust_northwind": { "integration_member_id": "13e0eb0e", "signing_token_env": "WORKABLE_WEBHOOK_TOKEN_NORTHWIND", }, } def verify_workable_signature(raw_body: bytes, signature: str, signing_token: str) -> bool: """Workable signs the raw payload with HMAC SHA256, keyed with the subscribing token.""" expected = hmac.new(signing_token.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature.strip().lower()) def handle_candidate_created(customer_id: str, raw_body: bytes, signature: str) -> None: customer = CUSTOMERS.get(customer_id) if customer is None: raise PermissionError("Unknown customer webhook path") if not verify_workable_signature(raw_body, signature, os.environ[customer["signing_token_env"]]): raise PermissionError("Invalid X-Workable-Signature") payload = json.loads(raw_body) candidate_ref = payload.get("data", payload) # envelope or bare candidate object candidate_id = candidate_ref["id"] member = customer["integration_member_id"] candidate = actions.execute_tool( tool_name="workable_candidate_get", tool_input={"candidate_id": candidate_id}, connection_name=CONNECTION_NAME, identifier=customer_id, ) profile = candidate.data.get("candidate", candidate.data) email = profile.get("email") if not email: return earlier = actions.execute_tool( tool_name="workable_candidates_list", tool_input={"email": email, "limit": 50}, connection_name=CONNECTION_NAME, identifier=customer_id, ) others = [ c for c in earlier.data.get("candidates", []) if c.get("id") != candidate_id ] print(candidate.execution_id, earlier.execution_id, candidate_id, len(others)) if not others: return existing_tags = profile.get("tags", []) if "returning-applicant" not in existing_tags: actions.execute_tool( tool_name="workable_candidate_tags_update", tool_input={ "candidate_id": candidate_id, "tags": existing_tags + ["returning-applicant"], }, connection_name=CONNECTION_NAME, identifier=customer_id, ) history = "; ".join( f"{c.get('job', {}).get('title', 'unknown job')} ({c.get('stage', 'unknown stage')}, " f"{'disqualified' if c.get('disqualified') else 'active'})" for c in others ) note = actions.execute_tool( tool_name="workable_candidate_comment_create", tool_input={ "candidate_id": candidate_id, "member_id": member, "body": f"Returning applicant. Earlier applications: {history}.", "policy": ["recruiter", "admin"], }, connection_name=CONNECTION_NAME, identifier=customer_id, ) print(note.execution_id, "flagged", candidate_id)

Workable replays any delivery that doesn't get a 200, so the tag check and a note keyed on the candidate keep repeats harmless. Workable documents the HMAC algorithm but not the encoding; the handler compares lowercase hex, which is what third-party integrations report, so log the first mismatch per customer rather than silently dropping it.

Workable documents no retry schedule, so pair the handler with reconciliation. workable_candidates_list takes updated_after and pages with since_id, and the loop below stays under the account-token limit by spacing calls one second apart.

import time from datetime import datetime, timedelta, timezone from urllib.parse import parse_qs, urlparse def reconcile_candidates(customer_id: str, hours: int = 26) -> list: since = (datetime.now(timezone.utc) - timedelta(hours=hours)).strftime("%Y-%m-%dT%H:%M:%SZ") changed = [] since_id = None while True: tool_input = {"updated_after": since, "limit": 100} if since_id: tool_input["since_id"] = since_id page = actions.execute_tool( tool_name="workable_candidates_list", tool_input=tool_input, connection_name=CONNECTION_NAME, identifier=customer_id, ) data = page.data if isinstance(page.data, dict) else {} changed.extend(data.get("candidates", [])) next_url = data.get("paging", {}).get("next") if not next_url: break since_id = parse_qs(urlparse(next_url).query).get("since_id", [None])[0] if since_id is None: break time.sleep(1.0) # account tokens allow 10 requests per 10 seconds, shared by every workload print(f"{len(changed)} candidates updated at {customer_id} since {since}") return changed

The 26-hour window overlaps the previous daily run by two hours, so a candidate updated during a slow job isn't lost. Compare the IDs against what your webhook handler processed and run the handler for the difference.

Reaching the Workable REST API

Webhook subscriptions are a one-time setup per customer, and they belong in your own code. Because the subscribing token becomes the signing key, ask each customer's admin for a second token with only r_candidates, use it to create the subscriptions, and store it separately from the token in the Scalekit vault. A leaked signing token then reads candidates; it can't move, tag, or comment on them. For other authenticated calls the prebuilt tools don't cover, Scalekit custom connectors apply.

import os import time import requests SUBDOMAIN = "northwind" # from Settings > Company profile, without .workable.com WORKABLE_API = f"https://{SUBDOMAIN}.workable.com/spi/v3" TOKEN = os.environ["WORKABLE_WEBHOOK_TOKEN_NORTHWIND"] # r_candidates only; never send to an LLM headers = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"} def post_with_backoff(url: str, body: dict) -> requests.Response: for _ in range(3): resp = requests.post(url, headers=headers, json=body, timeout=30) if resp.status_code != 429: return resp reset_at = float(resp.headers.get("X-Rate-Limit-Reset", time.time() + 10)) time.sleep(max(1.0, reset_at - time.time())) return resp for event in ("candidate_created", "candidate_moved"): resp = post_with_backoff( f"{WORKABLE_API}/subscriptions", { "target": f"https://agents.example.com/hooks/workable/cust_northwind/{event}", "event": event, }, ) if resp.status_code == 409: print(f"{event}: target already registered") continue resp.raise_for_status() print(event, "subscription id:", resp.json()["id"])

Store each subscription ID per customer, so offboarding can delete the subscriptions before the customer revokes the token.

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

A recruiting agent rarely stops at Workable. It finds stale candidates, then pings the hiring team in Slack. Load all 66 workable tools and the model holds workable_candidate_disqualify, workable_candidate_tags_update, workable_employee_update, and workable_requisition_approve while answering "who's waiting on feedback." At roughly 200 tokens each, 66 tools is about 13,200 tokens of definitions before the agent does any work, and Workable's own MCP server adds about 18,800 for its 94. A model choosing among workable_candidate_move, workable_candidate_relocate, and workable_candidate_copy picks the wrong one more often than a model holding only the one it needs, and two of the three change which job a candidate belongs to. 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 identifier's connected accounts. Docs: set up a virtual MCP server.

Create the server once per agent role

from scalekit.actions.models.mcp_config import McpConfigConnectionToolMapping vmcp = actions.mcp.create_config( name="workable-stale-pipeline-digest", description="Finds Workable candidates stuck in a stage and posts a weekly Slack digest per hiring team", connection_tool_mappings=[ McpConfigConnectionToolMapping( connection_name="workable", tools=[ "workable_jobs_list", "workable_job_stages_list", "workable_candidates_list", "workable_candidate_activities_list", ], ), McpConfigConnectionToolMapping( connection_name="slack", tools=["slack_send_message"], ), ], ) print(vmcp.config.id, vmcp.config.mcp_server_url)

Mint a session token per user, per run

One server definition serves every customer. Before each run, check that the identifier's accounts are active and mint a short-lived session token scoped to them. 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 user_id = "cust_northwind" accounts = actions.mcp.list_mcp_connected_accounts( config_id=config_id, identifier=user_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=user_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 66 plus everything Slack exposes, and none of them writes to Workable. The endpoint is static; the identity is per customer. Unlike Workable's own MCP server, this endpoint can carry tools Workable MCP doesn't have, such as workable_employee_create for the one onboarding role that should hold it, and leave them out of every other role. It also runs on a schedule with no one signed in, which Workable's member-scoped OAuth sessions can't.

Observability: every Workable action tied to a user

Every execute_tool call returns an execution_id, and every execution is attributed to a connected account, which maps to one customer company. When a recruiting lead asks which agent run tagged 40 candidates as returning applicants overnight, or who commented "flagged as stale" on a candidate their CEO referred, the answer is a lookup, not an investigation.

Workable adds a second layer that you control. On the API path, the member_id you pass is what Workable's activity stream shows as the actor. Pass a dedicated integration member for webhook-driven writes and the signed-in recruiter's member for writes they asked for, and workable_candidate_activities_list tells the same story as your execution log. Pass whatever the model produced and Workable's own record becomes fiction. On the MCP path, every action is the signed-in member's, which is accurate, but nothing outside that chat client knows which agent session took it. A single account token shared by a whole customer collapses every recruiter into one credential; the member_id is the only thing that separates them, and only if your code sets 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 recruiter's or hiring manager's assistant inside Claude, ChatGPT, or Cursor, Workable's official MCP server is a strong fit: it is GA, broad, and enforces each member's role and job assignments without any work on your side. If your agent reacts to new applicants, runs scheduled digests, writes employee records, or serves many customer companies from your own product, build on the Workable API, where you choose the token scopes, the tool surface, and the member_id on every write, and where the 10-request window and the signing-key-is-a-token rule become your design constraints. Either way, you hold an admin-generated Workable credential per customer that reaches every candidate in the account, and that's what needs production-grade infrastructure.

FAQs

Does Workable have an official MCP server?

Yes. Workable hosts an MCP server that went generally available on May 13, 2026 with 38 tools on every plan and now lists 94, covering jobs, candidates, offers, requisitions, members, employees, time off and performance reviews. It authenticates each member with OAuth and runs every call as that member, limited to their role and job assignments.

How does the Workable API authenticate?

Self-serve access uses an account token that only an admin can generate, with scopes such as read jobs or write candidates and an expiry between 30 days and 2 years. OAuth and partner tokens exist but are limited to approved Workable partners. An account token reaches every candidate in the account, not one recruiter's jobs.

Why does the member field matter on Workable API writes?

Moves, disqualifications and comments made with an account token require a member, which Workable records as the actor, and on HR endpoints that member's access level filters the result. Whoever controls that field controls whose identity the agent borrows, so in a multi-user agent your code must set it, never the model.

What can the Workable API do that the MCP server cannot?

The API delivers signed webhooks for new and moved candidates, creates and updates employee records, attaches employee documents and runs headless on a schedule. The MCP server only acts inside a conversation, and its employee tools are read and search only. Offer approval through the API needs a user-level token, which self-serve account tokens are not.

What are Workable's API rate limits?

Account tokens are limited to 10 requests per 10 seconds, and OAuth and partner tokens to 50, with rate-limit headers on every response and a 429 past the limit. The budget is shared by every workload on that customer's token, so an agent, a nightly sync and a reconciliation job all draw from the same window.

How does Scalekit connect an AI agent to Workable?

Each customer's Workable admin enters an access token once on a hosted page, and Scalekit stores it in a vault and injects it into every call, so the agent never sees it. The connector exposes 66 tools, 40 read and 26 write, which you narrow per agent role and serve as LangChain tools or through a virtual MCP server.

Build your Workable agent with Scalekit

Browse the Scalekit Workable connector and the Workable 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.