The Ashby API is an RPC-style HTTP API at https://api.ashbyhq.com
where you POST a JSON body to endpoints such as
candidate.list. You authenticate with an API key sent as the HTTP
Basic auth username with an empty password, and each key only reaches the
modules an Ashby admin granted it. With it you can pull candidates,
applications, jobs and resume files, sync changes incrementally and receive
webhooks.
| Base URL | https://api.ashbyhq.com/CATEGORY.method,
POST + JSON body |
| Auth | API key as Basic auth username, empty password; server-side only (no CORS) |
| Version | 2026-01-01; pinned per key, override with the
Ashby-Version header |
| Errors | HTTP 200 with success: false and
errorInfo.code |
| Pagination | cursor / nextCursor, 100 per page by
default; syncToken for incremental sync (valid 14 days) |
| Rate limits | Per API key, 429 when exceeded |
| Webhooks | HMAC-SHA256 in Ashby-Signature, up to 10 retries |
| Resume files | resumeFileHandle → file.info → download URL |
| Docs | developers.ashbyhq.com, checked October 29, 2026 |
1. Create an API key
Only Organization Admins can create keys, under Admin > Integrations > API Credentials. A new key starts with no permissions, and it is shown once, so copy it before closing the wizard. For a resume workflow, grant:
- Candidates read (
candidatesRead) forcandidate.list,candidate.info,application.listandfile.info. - Candidates write (
candidatesWrite) only if you post results back withcandidate.createNote. - Confidential jobs and private fields: off by default, so tick them if those candidates must be included.
The key goes in the username slot of Basic auth, so with curl it's
-u "$ASHBY_API_KEY:". Keep it on your server: Ashby
doesn't enable CORS, so proxy any front-end call through your backend. A
missing key returns 401; a wrong or deactivated one
403, and so does a missing permission (with
missing_endpoint_permission).
2. List candidates with pagination
Every list endpoint returns results,
moreDataAvailable and nextCursor. Send the cursor
back until moreDataAvailable is false; the last page carries a
syncToken that gets you only the records changed since.
Don't trust the status code alone. A rejected request usually comes back
as HTTP 200 with success: false and an
errorInfo holding a code, a message and
a requestId to log. A 429 that carries
X-RateLimit-Scope: integration-partner means a partner's
combined traffic was throttled, not your key alone.
The one thing to watch: tokens and cursors expire after 14 days, and an
incremental sync longer than 100 pages fails with
incremental_sync_too_large. In practice, run it at least daily
and restart from scratch on the documented error codes, wich is what this
client does.
# ashby.py: call the Ashby API and sync candidates page by page (Python 3.10+).
import os
import time
import requests
session = requests.Session()
session.auth = (os.environ["ASHBY_API_KEY"], "") # key as username, empty password
session.headers.update({"Accept": "application/json", "Content-Type": "application/json"})
RESTART = {"sync_token_expired", "sync_token_invalid", "incremental_sync_too_large",
"next_cursor_expired", "cursor_invalid", "invalid_next_cursor"} # drop token, start over
def ashby(method: str, payload: dict | None = None) -> dict:
for attempt in range(5):
r = session.post(f"https://api.ashbyhq.com/{method}", json=payload or {}, timeout=30)
if r.status_code == 429 or r.status_code >= 500:
time.sleep(2**attempt) # back off, then retry
continue
r.raise_for_status() # 401 missing key, 403 wrong key or missing permission
body = r.json()
if not body.get("success"): # client errors arrive as HTTP 200
raise RuntimeError((body.get("errorInfo") or {}).get("code", "ashby_error"))
return body
raise RuntimeError(f"{method} still failing after 5 attempts")
def sync_candidates(sync_token: str | None, handle) -> str | None:
cursor = None
while True:
payload = {"limit": 100, "syncToken": sync_token, "cursor": cursor}
try:
page = ashby("candidate.list", {k: v for k, v in payload.items() if v})
except RuntimeError as error:
if str(error) not in RESTART or not (sync_token or cursor):
raise
sync_token = cursor = None # clean full sync
continue
for candidate in page["results"]:
handle(candidate)
if not page.get("moreDataAvailable"):
return page.get("syncToken") # only on the last page: store it for next run
cursor = page["nextCursor"]application.list works the same way and also filters by
jobId, status, createdAfter and
createdBefore.
3. Download the resume
Each candidate has a resumeFileHandle (or null).
Pass its handle string to file.info, then download
the returned url right away rather than storing it. Other
attachments sit in the fileHandles array and work the same way.
# resume_file.py: turn a candidate's resume file handle into bytes.
import requests
from ashby import ashby
def download_resume(candidate: dict) -> tuple[str, bytes] | None:
handle = candidate.get("resumeFileHandle") # {"id", "name", "handle"} or None
if not handle:
return None
url = ashby("file.info", {"fileHandle": handle["handle"]})["results"]["url"]
r = requests.get(url, timeout=60) # plain GET: don't send Ashby credentials here
r.raise_for_status()
return handle["name"], r.contentHeads-up: file.info can fail on the generated demo data in demo
accounts. Honestly, a test candidate with a real resume you uploaded is the
easier thing to develop against.
4. Parse it with CV Extract
Ashby stores the file but doesn't give you the work history or skills
inside it as fields. The HireLayer CV Extract API takes the file in one multipart request with an X-API-Key header
and returns the candidate as JSON in the same response, for 1 credit per
successful parse (the Free plan includes 50 credits a month).
# parse.py: send the resume to HireLayer CV Extract and get JSON back.
import os
import requests
def parse_resume(file_name: str, content: bytes, candidate_id: str) -> dict | None:
r = requests.post(
"https://hirelayer.co/api/v3/parser",
headers={"X-API-Key": os.environ["HIRELAYER_API_KEY"]},
files={"file": (file_name, content)},
data={"application_id": f"ashby:{candidate_id}"}, # echoed in info_resume
timeout=150, # usually about 35 s; the gateway returns 504 after 145 s
)
if r.status_code in (400, 413, 415):
return None # bad, too large or unsupported file: don't resend it unchanged
r.raise_for_status() # 401 bad key, 403 no credits left, 5xx: retry later
return r.json()The response holds info_candidate (name, email, phone, location,
LinkedIn, job title, experience bracket), work_experiences and
educations with normalized dates, languages with
CEFR-based levels and typed skills, from PDF, Word or image files
(scans included) in 70 languages. Below is a trimmed respone: sending
ashby: plus the candidate id as application_id ties
every parse back to its Ashby record.
{
"info_resume": { "application_id": "ashby:3ae2b801-...", "language": "EN" },
"info_candidate": {
"full_name": "Alex Morgan",
"job_title": "Senior Software Engineer",
"experience_level": "5 to 10 years"
},
"work_experiences": [{ "company_name": "Northstar Labs", "start_date": "2022-03-01", "currently_active": true }],
"skills": [{ "skill_title": "TypeScript", "skill_type": "Software skill", "status": "normalized" }]
}Skip candidates whose resume file id you already parsed, so an
unrelated update doesn't cost another credit. For a large first import,
the bulk parsing guide covers concurrency and duplicates. On errors, follow the error reference: retry 5xx with backoff, never resend a 4xx
unchanged.
To show results inside Ashby, candidate.createNote takes a
candidateId and a note (plain text or HTML), and
customField.setValue fills a custom field you created, such as a
skills list. Both need Candidates write.
5. Keep in sync with webhooks
For new applicants, create a webhook under Admin > Integrations >
Webhooks on the applicationSubmit event, with a secret token. It
doesn't fire for bulk imports or integration syncs, so keep the daily
sync as a safty net. You can also register it from code with
webhook.create, which needs the API Keys write permission.
- Verify
Ashby-Signature(HMAC-SHA256 of the raw body) in constant time. - Answer the ping Ashby sends on save, or the webhook stays disabled.
- Reply fast and parse in the background: failed deliveries are retried up to
10 times, except on
401,403,404,405and410.
# webhook.py: parse resumes as applications arrive (pip install flask requests).
import hashlib
import hmac
import os
from concurrent.futures import ThreadPoolExecutor
from flask import Flask, abort, request
from ashby import ashby
from parse import parse_resume
from resume_file import download_resume
app = Flask(__name__)
SECRET = os.environ["ASHBY_WEBHOOK_SECRET"].encode()
workers = ThreadPoolExecutor(max_workers=4)
def process(candidate_id: str) -> None:
candidate = ashby("candidate.info", {"id": candidate_id})["results"]
resume = download_resume(candidate)
if resume:
parsed = parse_resume(*resume, candidate_id)
# store parsed, or post a summary with candidate.createNote
@app.post("/webhooks/ashby")
def ashby_webhook():
raw = request.get_data() # verify the raw body, before any JSON parsing
expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("Ashby-Signature", "")):
abort(401)
event = request.get_json()
if event.get("action") == "applicationSubmit":
workers.submit(process, event["data"]["application"]["candidate"]["id"])
return "", 200 # answer now, parse in the backgroundBonus: published jobs without a key
Ashby's read-only Job Postings API serves a job board's published
postings at GET https://api.ashbyhq.com/posting-api/job-board/
plus your board name, with no API key in the documented request. Feed the
descriptionPlain field to HireLayer Job Extract if you want each job's requirements as data.
Parse your Ashby resumes into JSON
Try CV Extract on a resume in the live demo, then plug the endpoint into your Ashby sync. The Free plan includes 50 credits a month.
Frequently asked questions
What is AshbyHQ?
Ashby (ashbyhq.com) is an all-in-one recruiting platform: an applicant tracking system with CRM and sourcing, scheduling and analytics.
Does Ashby have an open API?
Yes, a documented public API, but every call needs an admin-created API key. Only the Job Postings API reads published jobs without one.
Can I get a free Ashby API key?
Keys are created by an admin inside an Ashby account, and Ashby's help center lists them on the Foundations, Legacy Plus, Plus and Enterprise plans.
How do I download a candidate's resume with the Ashby API?
Send resumeFileHandle.handle from candidate.info
to file.info as fileHandle, then download the
url it returns.
Sources and further reading
- Ashby API reference: Introduction
- Ashby API reference: Authentication and permissions
- Ashby API reference: Versioning
- Ashby API reference: Responses & Errors
- Ashby changelog: X-RateLimit-Scope header on rate limit responses
- Ashby guide: Pagination and Incremental Synchronization
- Ashby API reference: candidate.list
- Ashby API reference: candidate.info
- Ashby API reference: application.list
- Ashby API reference: file.info
- Ashby API reference: candidate.createNote
- Ashby API reference: customField.setValue
- Ashby guide: Setting up Webhooks
- Ashby guide: Authenticating Webhooks
- Ashby guide: Webhook retries
- Ashby API reference: applicationSubmit webhook
- Ashby API reference: webhook.create
- Ashby guide: Job Postings API
- Ashby help center: How do I generate an API key?
- Ashby: all-in-one recruiting platform
- HireLayer API documentation: Parse a resume (V3)
- HireLayer API documentation: Errors and retries
Louis Desclous
Published on · Reading time: 5 minutes
