Developers
API, webhooks and MCP
Everything on ScrubHub is available through a REST API and an MCP server, so you can post listings from your ATS, pull applicants into your own pipeline, or let an agent draft compliant jobs. OpenAPI 3.1 document.
Quick start
- Create an API key under your organization’s Settings → API keys and pick scopes.
- Call
GET /api/v1/orgwithAuthorization: Bearer sh_live_…. - Install the TypeScript SDK:
npm i @scrubhub/api-client, or point your agent at the MCP server (npx @scrubhub/mcp, envSCRUBHUB_API_KEY).
curl https://scrubhub.ai/api/v1/org/jobs \
-H "Authorization: Bearer sh_live_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: req-123" \
-d '{"title":"Anesthesiologist — Care Team","employment_type":"full_time","internal_ref":"REQ-4471"}'Endpoints
REST API for ScrubHub.ai. Base path /api/v1.
- Auth:
Authorization: Bearer sh_live_…(org-scoped API key; create one under Settings → API keys). Public job-read endpoints need no key and are rate-limited by IP. - Scopes:
jobs:read,jobs:write,applications:read,applications:write,candidates:read,webhooks:manage,org:read. - Idempotency: send
Idempotency-Keyon POST/PUT/PATCH; the same key replays the original response for 24h. - Pagination: cursor-based (
?cursor=&limit=≤ 100) →{ data, next_cursor }. - Errors: RFC 9457
application/problem+jsonwitherrors[]for field-level issues. - Rate limits: 600 req/min per key;
RateLimit-*headers on every response. Every response carriesX-Request-Id. - Versioning: path (
/v1). Additive changes are not breaking.
Public
- get
/jobsSearch active jobs - get
/jobs/{id}Get a job - get
/specialtiesList specialties - get
/employers/{slug}Employer profile and active jobs - get
/clinicians/{npi}Clinician directory entry
Organization
- get
/orgCurrent organizationRequires scope `org:read`. - get
/org/membersList membersRequires scope `org:read`. - get
/org/pipeline-stagesList pipeline stagesRequires scope `org:read`. - put
/org/pipeline-stagesReplace pipeline stagesRequires scope `applications:write`.
Jobs
- get
/org/jobsList org jobsRequires scope `jobs:read`. - post
/org/jobsCreate a draft jobAccepts the full listing schema (§5). Validation happens at publish; drafts may be partial. Honors `Idempotency-Key`. - get
/org/jobs/{id}Get an org jobRequires scope `jobs:read`. - patch
/org/jobs/{id}Update a jobRequires scope `jobs:write`. - delete
/org/jobs/{id}Remove a jobRequires scope `jobs:write`. - post
/org/jobs/{id}/publishPublish (consumes a credit or returns a checkout URL)Requires scope `jobs:write`. - post
/org/jobs/{id}/pausePause a live jobRequires scope `jobs:write`. - post
/org/jobs/{id}/fillMark filledRequires scope `jobs:write`. - post
/org/jobs/{id}/screening-questionsReplace screening questionsRequires scope `jobs:write`. - get
/org/jobs/{id}/statsViews and applies by dayRequires scope `jobs:read`.
Applications
- get
/org/applicationsList applicationsRequires scope `applications:read`. - post
/org/applicationsAdd a non-ScrubHub candidateRequires scope `applications:write`. - get
/org/applications/{id}Get an application (with timeline)Requires scope `applications:read`. - patch
/org/applications/{id}Move stage / rate / tag / assignRequires scope `applications:write`. - post
/org/applications/{id}/rejectReject with reason and messageRequires scope `applications:write`. - post
/org/applications/{id}/notesAdd a private note (@mentions supported)Requires scope `applications:write`. - get
/org/applications/{id}/messagesThread messagesRequires scope `applications:read`. - post
/org/applications/{id}/messagesSend a message to the candidateRequires scope `applications:write`. - post
/org/applications/{id}/interviewsPropose interview slotsRequires scope `applications:write`. - post
/org/applications/{id}/offerMake a structured offerRequires scope `applications:write`. - post
/org/applications/{id}/request-identityAsk the candidate to reveal identityRequires scope `applications:write`. - get
/org/applications/{id}/documents/{doc_id}Download a candidate document302 to a presigned URL valid ≤ 10 minutes; access is audit-logged.
Candidates
- get
/org/candidatesSearch anonymous candidate profilesRequires an employer account subscription (§8.6); otherwise 402. Blocked candidates are excluded. - post
/org/candidates/{anon_id}/inviteInvite a candidate to applyLimited to 50 invites per day per org.
Webhooks
- get
/org/webhooksList webhook endpointsRequires scope `webhooks:manage`. - post
/org/webhooksCreate a webhook endpointThe signing secret is returned once. Verify `X-ScrubHub-Signature: t=…,v1=HMAC_SHA256(secret, t + "." + body)`. - delete
/org/webhooks/{id}Delete a webhook endpointRequires scope `webhooks:manage`. - post
/org/webhooks/{id}/testSend a test eventRequires scope `webhooks:manage`.
Me
- get
/meCurrent user and candidate profile - get
/me/applicationsMy applications - get
/me/applications/{id}One of my applications (with timeline) - post
/me/applications/{id}/withdrawWithdraw an application - get
/me/alertsMy job alerts - post
/me/alertsCreate a job alert - delete
/me/alerts/{id}Delete a job alert
Webhooks
Events: job.published, job.expired, job.filled, application.created, application.stage_changed, application.withdrawn, application.identity_revealed, message.received, interview.confirmed, offer.accepted, offer.declined. Payload { id, type, created_at, org_id, data }. Deliveries retry with exponential backoff for about 24 hours; an endpoint is disabled after 50 consecutive failures and you are emailed.
Verify the signature (Node)
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifyScrubHub(secret, rawBody, header, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
const t = Number(parts.t)
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
return expected.length === parts.v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
}Verify the signature (Python)
import hmac, hashlib, time
def verify_scrubhub(secret: str, raw_body: bytes, header: str, tolerance=300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts["t"])
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])MCP server
@scrubhub/mcp exposes the same API as tools over stdio and streamable HTTP: search_jobs, get_job, create_job_draft, publish_job, list_applications, get_application, move_application_stage, reject_application, send_message, schedule_interview, make_offer, search_candidates, invite_candidate, list_pipeline_stages. The resource scrubhub://listing-schema/{schema_key} returns the JSON Schema for each setting block so an agent can draft a compliant listing.
{
"mcpServers": {
"scrubhub": { "command": "npx", "args": ["-y", "@scrubhub/mcp"], "env": { "SCRUBHUB_API_KEY": "sh_live_…", "SCRUBHUB_API_URL": "https://scrubhub.ai/api/v1" } }
}
}